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.
This commit is contained in:
Benoit Marty 2025-05-13 11:54:58 +02:00 committed by GitHub
parent 3cf8b04a07
commit 93499910ba
24 changed files with 341 additions and 49 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
val releaseAudioFocusResult = lambdaRecorder<Unit> {}
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<AudioFocusRequester, () -> 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<Unit> { }
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem }
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> 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,
)
}

View file

@ -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()