From 0255111528770b2261d2f9708eca98625bc54ed6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 13 May 2025 11:54:58 +0200 Subject: [PATCH] Improve audio focus management (#4707) * Extract Audio focus management to its own modules. * Request Audio focus when playing a voice message. * Add missing dependency. (and remove a duplicated one) * Request Audio focus when playing a video/audio in the media viewer. * Pause audio when audio focus is lost. * Rename class * Fix tests * Fix detekt issue. * Audio focus: let the system handle automatic ducking when playing media. * Document and update API * Remove useless space. --- features/call/impl/build.gradle.kts | 1 + .../call/impl/ui/ElementCallActivity.kt | 54 ++------- libraries/audio/api/build.gradle.kts | 13 +++ .../android/libraries/audio/api/AudioFocus.kt | 32 ++++++ libraries/audio/impl/build.gradle.kts | 25 +++++ .../libraries/audio/impl/DefaultAudioFocus.kt | 106 ++++++++++++++++++ libraries/audio/test/build.gradle.kts | 19 ++++ .../mediaplayer/test/FakeAudioFocus.kt | 28 +++++ libraries/mediaplayer/impl/build.gradle.kts | 2 + .../mediaplayer/impl/DefaultMediaPlayer.kt | 12 ++ .../mediaplayer/impl/SimplePlayer.kt | 3 + .../impl/DefaultMediaPlayerTest.kt | 30 ++++- .../mediaplayer/impl/FakeSimplePlayer.kt | 2 + libraries/mediaviewer/impl/build.gradle.kts | 1 + .../impl/local/DefaultLocalMediaRenderer.kt | 5 +- .../mediaviewer/impl/local/LocalMediaView.kt | 4 + .../impl/local/audio/MediaAudioView.kt | 6 + .../local/player/MediaPlayerControllerView.kt | 24 ++++ .../impl/local/video/MediaVideoView.kt | 6 + .../impl/viewer/MediaViewerNode.kt | 5 +- .../impl/viewer/MediaViewerView.kt | 8 +- .../impl/viewer/MediaViewerViewTest.kt | 1 + libraries/voiceplayer/impl/build.gradle.kts | 1 + .../kotlin/extension/DependencyHandleScope.kt | 2 +- 24 files changed, 341 insertions(+), 49 deletions(-) create mode 100644 libraries/audio/api/build.gradle.kts create mode 100644 libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt create mode 100644 libraries/audio/impl/build.gradle.kts create mode 100644 libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt create mode 100644 libraries/audio/test/build.gradle.kts create mode 100644 libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index 343da45b47..13922366c2 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) + implementation(projects.libraries.audio.api) implementation(projects.libraries.core) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index 90f89ce7c6..4c44fb29d9 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -10,9 +10,6 @@ package io.element.android.features.call.impl.ui import android.Manifest import android.app.PictureInPictureParams import android.content.Intent -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager import android.os.Build import android.os.Bundle import android.util.Rational @@ -46,6 +43,8 @@ import io.element.android.features.call.impl.utils.CallIntentDataParser import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.theme.ElementThemeApp @@ -65,16 +64,12 @@ class ElementCallActivity : @Inject lateinit var enterpriseService: EnterpriseService @Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter @Inject lateinit var buildMeta: BuildMeta + @Inject lateinit var audioFocus: AudioFocus private lateinit var presenter: Presenter - private lateinit var audioManager: AudioManager - private var requestPermissionCallback: RequestPermissionCallback? = null - private var audiofocusRequest: AudioFocusRequest? = null - private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null - private val requestPermissionsLauncher = registerPermissionResultLauncher() private val webViewTarget = mutableStateOf(null) @@ -102,8 +97,6 @@ class ElementCallActivity : pictureInPicturePresenter.setPipView(this) - audioManager = getSystemService(AUDIO_SERVICE) as AudioManager - setContent { val pipState = pictureInPicturePresenter.present() ListenToAndroidEvents(pipState) @@ -133,7 +126,13 @@ class ElementCallActivity : } private fun setCallIsActive() { - requestAudioFocus() + audioFocus.requestAudioFocus( + requester = AudioFocusRequester.ElementCall, + onFocusLost = { + // If the audio focus is lost, we do not stop the call. + Timber.tag(loggerTag.value).w("Audio focus lost") + } + ) CallForegroundService.start(this) } @@ -175,7 +174,7 @@ class ElementCallActivity : override fun onDestroy() { super.onDestroy() - releaseAudioFocus() + audioFocus.releaseAudioFocus() CallForegroundService.stop(this) pictureInPicturePresenter.setPipView(null) } @@ -241,37 +240,6 @@ class ElementCallActivity : } } - @Suppress("DEPRECATION") - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val audioAttributes = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .build() - val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes(audioAttributes) - .build() - audioManager.requestAudioFocus(request) - audiofocusRequest = request - } else { - val listener = AudioManager.OnAudioFocusChangeListener { } - audioManager.requestAudioFocus( - listener, - AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, - ) - audioFocusChangeListener = listener - } - } - - @Suppress("DEPRECATION") - private fun releaseAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audiofocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } - } else { - audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) } - } - } - @RequiresApi(Build.VERSION_CODES.O) override fun setPipParams() { setPictureInPictureParams(getPictureInPictureParams()) diff --git a/libraries/audio/api/build.gradle.kts b/libraries/audio/api/build.gradle.kts new file mode 100644 index 0000000000..3a82763787 --- /dev/null +++ b/libraries/audio/api/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.audio.api" +} diff --git a/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt new file mode 100644 index 0000000000..c363114856 --- /dev/null +++ b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.audio.api + +enum class AudioFocusRequester { + ElementCall, + VoiceMessage, + MediaViewer, +} + +interface AudioFocus { + /** + * Request audio focus for the given requester. + * @param requester The mode for which to request audio focus. + * @param onFocusLost Callback to be invoked when the audio focus is lost. + * @return true if the audio focus was successfully requested, false otherwise. + */ + fun requestAudioFocus( + requester: AudioFocusRequester, + onFocusLost: () -> Unit, + ) + + /** + * Release the audio focus. + */ + fun releaseAudioFocus() +} diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts new file mode 100644 index 0000000000..61a4cd7eba --- /dev/null +++ b/libraries/audio/impl/build.gradle.kts @@ -0,0 +1,25 @@ +import extension.setupAnvil + +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.audio.impl" +} + +setupAnvil() + +dependencies { + api(projects.libraries.audio.api) + + implementation(libs.androidx.corektx) + implementation(libs.dagger) + implementation(projects.libraries.di) +} diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt new file mode 100644 index 0000000000..057339d60c --- /dev/null +++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.audio.impl + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import androidx.core.content.getSystemService +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAudioFocus @Inject constructor( + @ApplicationContext private val context: Context, +) : AudioFocus { + private val audioManager = requireNotNull(context.getSystemService()) + + private var audioFocusRequest: AudioFocusRequest? = null + private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null + + @Suppress("DEPRECATION") + override fun requestAudioFocus( + requester: AudioFocusRequester, + onFocusLost: () -> Unit, + ) { + val listener = AudioManager.OnAudioFocusChangeListener { + when (it) { + AudioManager.AUDIOFOCUS_GAIN -> { + // Do nothing + } + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + onFocusLost() + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val audioAttributes = AudioAttributes.Builder() + .setUsage(requester.toAudioUsage()) + .build() + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(audioAttributes) + .setOnAudioFocusChangeListener(listener) + .setWillPauseWhenDucked(requester.willPausedWhenDucked()) + .build() + audioManager.requestAudioFocus(request) + audioFocusRequest = request + } else { + audioManager.requestAudioFocus( + listener, + requester.toAudioStream(), + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) + audioFocusChangeListener = listener + } + } + + @Suppress("DEPRECATION") + override fun releaseAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } else { + audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) } + } + } +} + +private fun AudioFocusRequester.toAudioUsage(): Int { + return when (this) { + AudioFocusRequester.ElementCall, + AudioFocusRequester.VoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA + } +} + +private fun AudioFocusRequester.toAudioStream(): Int { + return when (this) { + AudioFocusRequester.ElementCall, + AudioFocusRequester.VoiceMessage -> AudioManager.STREAM_VOICE_CALL + AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC + } +} + +private fun AudioFocusRequester.willPausedWhenDucked(): Boolean { + return when (this) { + // (note that for Element Call, there is no action when the focus is lost) + AudioFocusRequester.ElementCall, + AudioFocusRequester.VoiceMessage -> true + // For the MediaViewer, we let the system automatically handle the ducking + // https://developer.android.com/media/optimize/audio-focus#automatic-ducking + AudioFocusRequester.MediaViewer -> false + } +} diff --git a/libraries/audio/test/build.gradle.kts b/libraries/audio/test/build.gradle.kts new file mode 100644 index 0000000000..57ffffbee7 --- /dev/null +++ b/libraries/audio/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.audio.test" +} + +dependencies { + api(projects.libraries.audio.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt new file mode 100644 index 0000000000..bb628e4cf9 --- /dev/null +++ b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.test + +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAudioFocus( + private val requestAudioFocusResult: (AudioFocusRequester, () -> Unit) -> Unit = { _, _ -> lambdaError() }, + private val releaseAudioFocusResult: () -> Unit = { lambdaError() }, +) : AudioFocus { + override fun requestAudioFocus( + requester: AudioFocusRequester, + onFocusLost: () -> Unit, + ) { + requestAudioFocusResult(requester, onFocusLost) + } + + override fun releaseAudioFocus() { + releaseAudioFocusResult() + } +} diff --git a/libraries/mediaplayer/impl/build.gradle.kts b/libraries/mediaplayer/impl/build.gradle.kts index 7b6e8e3d6e..9d7bdfd1ae 100644 --- a/libraries/mediaplayer/impl/build.gradle.kts +++ b/libraries/mediaplayer/impl/build.gradle.kts @@ -21,11 +21,13 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.dagger) + implementation(projects.libraries.audio.api) implementation(projects.libraries.di) implementation(libs.coroutines.core) testImplementation(projects.tests.testutils) + testImplementation(projects.libraries.audio.test) testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.test.mockk) diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt index eca3b4c87a..cda3bd94b3 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt @@ -11,6 +11,8 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.SingleIn import io.element.android.libraries.mediaplayer.api.MediaPlayer @@ -36,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds class DefaultMediaPlayer @Inject constructor( private val player: SimplePlayer, private val coroutineScope: CoroutineScope, + private val audioFocus: AudioFocus, ) : MediaPlayer { private val listener = object : SimplePlayer.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -49,6 +52,7 @@ class DefaultMediaPlayer @Inject constructor( if (isPlaying) { job = coroutineScope.launch { updateCurrentPosition() } } else { + audioFocus.releaseAudioFocus() job?.cancel() } } @@ -118,6 +122,14 @@ class DefaultMediaPlayer @Inject constructor( } override fun play() { + audioFocus.requestAudioFocus( + requester = AudioFocusRequester.VoiceMessage, + onFocusLost = { + if (player.isPlaying()) { + player.pause() + } + }, + ) if (player.playbackState == Player.STATE_ENDED) { // There's a bug with some ogg files that somehow report to // have no duration. diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index 2f24ceefbd..df1413161e 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -30,6 +30,7 @@ interface SimplePlayer { fun getCurrentMediaItem(): MediaItem? fun prepare() fun play() + fun isPlaying(): Boolean fun pause() fun seekTo(positionMs: Long) fun release() @@ -80,6 +81,8 @@ class DefaultSimplePlayer( override fun play() = p.play() + override fun isPlaying() = p.isPlaying + override fun pause() = p.pause() override fun seekTo(positionMs: Long) = p.seekTo(positionMs) diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt index d013a22a65..8a36671fc2 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt @@ -11,7 +11,10 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.TimeoutCancellationException @@ -50,8 +53,15 @@ class DefaultMediaPlayerTest { playLambda = playLambda, pauseLambda = pauseLambda, ) + val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + val releaseAudioFocusResult = lambdaRecorder {} + val audioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + releaseAudioFocusResult = releaseAudioFocusResult + ) val sut = createDefaultMediaPlayer( simplePlayer = player, + audioFocus = audioFocus, ) sut.state.test { val initialState = awaitItem() @@ -67,6 +77,7 @@ class DefaultMediaPlayerTest { ) sut.play() playLambda.assertions().isCalledOnce() + requestAudioFocusResult.assertions().isCalledOnce() player.durationResult = 123L player.simulateIsPlayingChanged(true) val playingState = awaitItem() @@ -105,6 +116,7 @@ class DefaultMediaPlayerTest { player.pause() pauseLambda.assertions().isCalledOnce() player.simulateIsPlayingChanged(false) + releaseAudioFocusResult.assertions().isCalledOnce() assertThat(awaitItem()).isEqualTo( MediaPlayer.State( isReady = false, @@ -126,8 +138,13 @@ class DefaultMediaPlayerTest { playLambda = playLambda, getCurrentMediaItemLambda = getCurrentMediaItemLambda, ) + val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + val audioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + ) val sut = createDefaultMediaPlayer( simplePlayer = player, + audioFocus = audioFocus, ) sut.state.test { val initialState = awaitItem() @@ -144,6 +161,7 @@ class DefaultMediaPlayerTest { player.playbackStateResult = Player.STATE_ENDED sut.play() playLambda.assertions().isCalledOnce() + requestAudioFocusResult.assertions().isCalledOnce() } } @@ -153,6 +171,10 @@ class DefaultMediaPlayerTest { val prepareLambda = lambdaRecorder { } val getCurrentMediaItemLambda = lambdaRecorder { aMediaItem } val setMediaItemLambda = lambdaRecorder { _, _ -> } + val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + val audioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + ) val player = FakeSimplePlayer( playLambda = playLambda, prepareLambda = prepareLambda, @@ -161,6 +183,7 @@ class DefaultMediaPlayerTest { ) val sut = createDefaultMediaPlayer( simplePlayer = player, + audioFocus = audioFocus, ) sut.state.test { val initialState = awaitItem() @@ -182,6 +205,7 @@ class DefaultMediaPlayerTest { ) prepareLambda.assertions().isCalledOnce() playLambda.assertions().isCalledOnce() + requestAudioFocusResult.assertions().isCalledOnce() } } @@ -395,8 +419,10 @@ class DefaultMediaPlayerTest { private fun TestScope.createDefaultMediaPlayer( simplePlayer: SimplePlayer = FakeSimplePlayer(), + audioFocus: AudioFocus = FakeAudioFocus(), ): DefaultMediaPlayer = DefaultMediaPlayer( - simplePlayer, - backgroundScope, + player = simplePlayer, + coroutineScope = backgroundScope, + audioFocus = audioFocus, ) } diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt index 76e1eec34d..94c880a862 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt @@ -16,6 +16,7 @@ class FakeSimplePlayer( private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() }, private val prepareLambda: () -> Unit = { lambdaError() }, private val playLambda: () -> Unit = { lambdaError() }, + private val isPlayingLambda: () -> Boolean = { lambdaError() }, private val pauseLambda: () -> Unit = { lambdaError() }, private val seekToLambda: (Long) -> Unit = { lambdaError() }, private val releaseLambda: () -> Unit = { lambdaError() }, @@ -40,6 +41,7 @@ class FakeSimplePlayer( override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda() override fun prepare() = prepareLambda() override fun play() = playLambda() + override fun isPlaying() = isPlayingLambda() override fun pause() = pauseLambda() override fun seekTo(positionMs: Long) = seekToLambda(positionMs) override fun release() = releaseLambda() diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index 46a9944a84..311a0441c8 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.features.viewfolder.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) + implementation(projects.libraries.audio.api) implementation(projects.libraries.core) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.di) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt index 3d3276c612..41c292a921 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.di.AppScope import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer @@ -23,6 +24,7 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultLocalMediaRenderer @Inject constructor( private val textFileViewer: TextFileViewer, + private val audioFocus: AudioFocus, ) : LocalMediaRenderer { @Composable override fun Render(localMedia: LocalMedia) { @@ -37,7 +39,8 @@ class DefaultLocalMediaRenderer @Inject constructor( localMedia = localMedia, localMediaViewState = localMediaViewState, textFileViewer = textFileViewer, - onClick = {} + audioFocus = audioFocus, + onClick = {}, ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt index bec02c7fd8..1ce9bd33eb 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.local import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage @@ -27,6 +28,7 @@ import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView fun LocalMediaView( localMedia: LocalMedia?, bottomPaddingInPixels: Int, + audioFocus: AudioFocus?, onClick: () -> Unit, textFileViewer: TextFileViewer, modifier: Modifier = Modifier, @@ -49,6 +51,7 @@ fun LocalMediaView( bottomPaddingInPixels = bottomPaddingInPixels, localMedia = localMedia, autoplay = isUserSelected, + audioFocus = audioFocus, modifier = modifier, ) mimeType == MimeTypes.PlainText -> TextFileView( @@ -68,6 +71,7 @@ fun LocalMediaView( bottomPaddingInPixels = bottomPaddingInPixels, localMedia = localMedia, info = mediaInfo, + audioFocus = audioFocus, modifier = modifier, ) else -> MediaFileView( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt index 028a25999f..729e2c1605 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt @@ -52,6 +52,7 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -80,6 +81,7 @@ fun MediaAudioView( bottomPaddingInPixels: Int, localMedia: LocalMedia?, info: MediaInfo?, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, isDisplayed: Boolean = true, ) { @@ -91,6 +93,7 @@ fun MediaAudioView( exoPlayer = exoPlayer, localMedia = localMedia, info = info, + audioFocus = audioFocus, modifier = modifier, ) } @@ -104,6 +107,7 @@ private fun ExoPlayerMediaAudioView( exoPlayer: ExoPlayer, localMedia: LocalMedia?, info: MediaInfo?, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, ) { var mediaPlayerControllerState: MediaPlayerControllerState by remember { @@ -294,6 +298,7 @@ private fun ExoPlayerMediaAudioView( onToggleMute = { // Cannot happen for audio files }, + audioFocus = audioFocus, modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) @@ -369,6 +374,7 @@ internal fun MediaAudioViewPreview( bottomPaddingInPixels = 0, localMediaViewState = rememberLocalMediaViewState(), info = info, + audioFocus = null, localMedia = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt index 67868be7dc..e4add62aab 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt @@ -19,9 +19,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.dateformatter.api.toHumanReadableDuration import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -41,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Slider import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency import io.element.android.libraries.ui.strings.CommonStrings +import timber.log.Timber @Composable fun MediaPlayerControllerView( @@ -48,8 +53,26 @@ fun MediaPlayerControllerView( onTogglePlay: () -> Unit, onSeekChange: (Float) -> Unit, onToggleMute: () -> Unit, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, ) { + if (audioFocus != null) { + val latestOnTogglePlay by rememberUpdatedState(onTogglePlay) + LaunchedEffect(state.isPlaying) { + if (state.isPlaying) { + audioFocus.requestAudioFocus( + requester = AudioFocusRequester.MediaViewer, + onFocusLost = { + Timber.w("Audio focus lost") + latestOnTogglePlay() + }, + ) + } else { + audioFocus.releaseAudioFocus() + } + } + } + AnimatedVisibility( visible = state.isVisible, modifier = modifier, @@ -167,5 +190,6 @@ internal fun MediaPlayerControllerViewPreview( onTogglePlay = {}, onSeekChange = {}, onToggleMute = {}, + audioFocus = null, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt index ddd53fe8dd..bba3c47f43 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt @@ -37,6 +37,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toDp @@ -63,6 +64,7 @@ fun MediaVideoView( bottomPaddingInPixels: Int, localMedia: LocalMedia?, autoplay: Boolean, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, ) { val exoPlayer = rememberExoPlayer() @@ -73,6 +75,7 @@ fun MediaVideoView( exoPlayer = exoPlayer, localMedia = localMedia, autoplay = autoplay, + audioFocus = audioFocus, modifier = modifier, ) } @@ -86,6 +89,7 @@ private fun ExoPlayerMediaVideoView( exoPlayer: ExoPlayer, localMedia: LocalMedia?, autoplay: Boolean, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, ) { var mediaPlayerControllerState: MediaPlayerControllerState by remember { @@ -248,6 +252,7 @@ private fun ExoPlayerMediaVideoView( autoHideController++ exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f }, + audioFocus = audioFocus, modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) @@ -278,6 +283,7 @@ internal fun MediaVideoViewPreview() = ElementPreview { bottomPaddingInPixels = 0, localMediaViewState = rememberLocalMediaViewState(), localMedia = null, + audioFocus = null, autoplay = false, ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 3cc44165b0..2da3e1c3f2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -19,6 +19,7 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.compound.theme.ForcedDarkElementTheme import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId @@ -44,6 +45,7 @@ class MediaViewerNode @AssistedInject constructor( systemClock: SystemClock, pagerKeysHandler: PagerKeysHandler, private val textFileViewer: TextFileViewer, + private val audioFocus: AudioFocus, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -129,7 +131,8 @@ class MediaViewerNode @AssistedInject constructor( state = state, textFileViewer = textFileViewer, modifier = modifier, - onBackClick = ::onDone + audioFocus = audioFocus, + onBackClick = ::onDone, ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index a55846106b..d365362c78 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -51,6 +51,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.components.async.AsyncFailure @@ -91,6 +92,7 @@ fun MediaViewerView( state: MediaViewerState, textFileViewer: TextFileViewer, onBackClick: () -> Unit, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -163,6 +165,7 @@ fun MediaViewerView( onShowOverlayChange = { showOverlay = it }, + audioFocus = audioFocus, isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId, ) // Bottom bar @@ -284,6 +287,7 @@ private fun MediaViewerPage( onRetry: () -> Unit, onDismissError: () -> Unit, onShowOverlayChange: (Boolean) -> Unit, + audioFocus: AudioFocus?, modifier: Modifier = Modifier, ) { val currentShowOverlay by rememberUpdatedState(showOverlay) @@ -336,6 +340,7 @@ private fun MediaViewerPage( } }, isUserSelected = isUserSelected, + audioFocus = audioFocus, ) ThumbnailView( mediaInfo = data.mediaInfo, @@ -578,7 +583,8 @@ private fun ErrorView( internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { MediaViewerView( state = state, + audioFocus = null, textFileViewer = { _, _ -> }, - onBackClick = {} + onBackClick = {}, ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt index 8bd23c2089..ba9881a786 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -252,6 +252,7 @@ private fun AndroidComposeTestRule.setMedia setContent { MediaViewerView( state = state, + audioFocus = null, textFileViewer = { _, _ -> }, onBackClick = onBackClick, ) diff --git a/libraries/voiceplayer/impl/build.gradle.kts b/libraries/voiceplayer/impl/build.gradle.kts index 6566f3557e..f579586aa0 100644 --- a/libraries/voiceplayer/impl/build.gradle.kts +++ b/libraries/voiceplayer/impl/build.gradle.kts @@ -19,6 +19,7 @@ setupAnvil() dependencies { api(projects.libraries.voiceplayer.api) + implementation(projects.libraries.audio.api) implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index d7fbdfae2c..7319c03111 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -68,7 +68,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:eventformatter:impl")) implementation(project(":libraries:indicator:impl")) implementation(project(":libraries:permissions:impl")) - implementation(project(":libraries:push:impl")) + implementation(project(":libraries:audio:impl")) implementation(project(":libraries:push:impl")) implementation(project(":libraries:featureflag:impl")) implementation(project(":libraries:pushstore:impl"))