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

@ -67,6 +67,7 @@ dependencies {
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)

View file

@ -10,9 +10,6 @@ package io.element.android.features.call.impl.ui
import android.Manifest import android.Manifest
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Intent import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Rational 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.features.enterprise.api.EnterpriseService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings 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.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
@ -65,16 +64,12 @@ class ElementCallActivity :
@Inject lateinit var enterpriseService: EnterpriseService @Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter @Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
@Inject lateinit var buildMeta: BuildMeta @Inject lateinit var buildMeta: BuildMeta
@Inject lateinit var audioFocus: AudioFocus
private lateinit var presenter: Presenter<CallScreenState> private lateinit var presenter: Presenter<CallScreenState>
private lateinit var audioManager: AudioManager
private var requestPermissionCallback: RequestPermissionCallback? = null private var requestPermissionCallback: RequestPermissionCallback? = null
private var audiofocusRequest: AudioFocusRequest? = null
private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
private val requestPermissionsLauncher = registerPermissionResultLauncher() private val requestPermissionsLauncher = registerPermissionResultLauncher()
private val webViewTarget = mutableStateOf<CallType?>(null) private val webViewTarget = mutableStateOf<CallType?>(null)
@ -102,8 +97,6 @@ class ElementCallActivity :
pictureInPicturePresenter.setPipView(this) pictureInPicturePresenter.setPipView(this)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
setContent { setContent {
val pipState = pictureInPicturePresenter.present() val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState) ListenToAndroidEvents(pipState)
@ -133,7 +126,13 @@ class ElementCallActivity :
} }
private fun setCallIsActive() { 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) CallForegroundService.start(this)
} }
@ -175,7 +174,7 @@ class ElementCallActivity :
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
releaseAudioFocus() audioFocus.releaseAudioFocus()
CallForegroundService.stop(this) CallForegroundService.stop(this)
pictureInPicturePresenter.setPipView(null) 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) @RequiresApi(Build.VERSION_CODES.O)
override fun setPipParams() { override fun setPipParams() {
setPictureInPictureParams(getPictureInPictureParams()) setPictureInPictureParams(getPictureInPictureParams())

View file

@ -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"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,11 +21,13 @@ dependencies {
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.audio.test)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)

View file

@ -11,6 +11,8 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import com.squareup.anvil.annotations.ContributesBinding 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.RoomScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
@ -36,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds
class DefaultMediaPlayer @Inject constructor( class DefaultMediaPlayer @Inject constructor(
private val player: SimplePlayer, private val player: SimplePlayer,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val audioFocus: AudioFocus,
) : MediaPlayer { ) : MediaPlayer {
private val listener = object : SimplePlayer.Listener { private val listener = object : SimplePlayer.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -49,6 +52,7 @@ class DefaultMediaPlayer @Inject constructor(
if (isPlaying) { if (isPlaying) {
job = coroutineScope.launch { updateCurrentPosition() } job = coroutineScope.launch { updateCurrentPosition() }
} else { } else {
audioFocus.releaseAudioFocus()
job?.cancel() job?.cancel()
} }
} }
@ -118,6 +122,14 @@ class DefaultMediaPlayer @Inject constructor(
} }
override fun play() { override fun play() {
audioFocus.requestAudioFocus(
requester = AudioFocusRequester.VoiceMessage,
onFocusLost = {
if (player.isPlaying()) {
player.pause()
}
},
)
if (player.playbackState == Player.STATE_ENDED) { if (player.playbackState == Player.STATE_ENDED) {
// There's a bug with some ogg files that somehow report to // There's a bug with some ogg files that somehow report to
// have no duration. // have no duration.

View file

@ -30,6 +30,7 @@ interface SimplePlayer {
fun getCurrentMediaItem(): MediaItem? fun getCurrentMediaItem(): MediaItem?
fun prepare() fun prepare()
fun play() fun play()
fun isPlaying(): Boolean
fun pause() fun pause()
fun seekTo(positionMs: Long) fun seekTo(positionMs: Long)
fun release() fun release()
@ -80,6 +81,8 @@ class DefaultSimplePlayer(
override fun play() = p.play() override fun play() = p.play()
override fun isPlaying() = p.isPlaying
override fun pause() = p.pause() override fun pause() = p.pause()
override fun seekTo(positionMs: Long) = p.seekTo(positionMs) 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 androidx.media3.common.Player
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
@ -50,8 +53,15 @@ class DefaultMediaPlayerTest {
playLambda = playLambda, playLambda = playLambda,
pauseLambda = pauseLambda, pauseLambda = pauseLambda,
) )
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
val releaseAudioFocusResult = lambdaRecorder<Unit> {}
val audioFocus = FakeAudioFocus(
requestAudioFocusResult = requestAudioFocusResult,
releaseAudioFocusResult = releaseAudioFocusResult
)
val sut = createDefaultMediaPlayer( val sut = createDefaultMediaPlayer(
simplePlayer = player, simplePlayer = player,
audioFocus = audioFocus,
) )
sut.state.test { sut.state.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -67,6 +77,7 @@ class DefaultMediaPlayerTest {
) )
sut.play() sut.play()
playLambda.assertions().isCalledOnce() playLambda.assertions().isCalledOnce()
requestAudioFocusResult.assertions().isCalledOnce()
player.durationResult = 123L player.durationResult = 123L
player.simulateIsPlayingChanged(true) player.simulateIsPlayingChanged(true)
val playingState = awaitItem() val playingState = awaitItem()
@ -105,6 +116,7 @@ class DefaultMediaPlayerTest {
player.pause() player.pause()
pauseLambda.assertions().isCalledOnce() pauseLambda.assertions().isCalledOnce()
player.simulateIsPlayingChanged(false) player.simulateIsPlayingChanged(false)
releaseAudioFocusResult.assertions().isCalledOnce()
assertThat(awaitItem()).isEqualTo( assertThat(awaitItem()).isEqualTo(
MediaPlayer.State( MediaPlayer.State(
isReady = false, isReady = false,
@ -126,8 +138,13 @@ class DefaultMediaPlayerTest {
playLambda = playLambda, playLambda = playLambda,
getCurrentMediaItemLambda = getCurrentMediaItemLambda, getCurrentMediaItemLambda = getCurrentMediaItemLambda,
) )
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
val audioFocus = FakeAudioFocus(
requestAudioFocusResult = requestAudioFocusResult,
)
val sut = createDefaultMediaPlayer( val sut = createDefaultMediaPlayer(
simplePlayer = player, simplePlayer = player,
audioFocus = audioFocus,
) )
sut.state.test { sut.state.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -144,6 +161,7 @@ class DefaultMediaPlayerTest {
player.playbackStateResult = Player.STATE_ENDED player.playbackStateResult = Player.STATE_ENDED
sut.play() sut.play()
playLambda.assertions().isCalledOnce() playLambda.assertions().isCalledOnce()
requestAudioFocusResult.assertions().isCalledOnce()
} }
} }
@ -153,6 +171,10 @@ class DefaultMediaPlayerTest {
val prepareLambda = lambdaRecorder<Unit> { } val prepareLambda = lambdaRecorder<Unit> { }
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem } val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem }
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> } val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
val audioFocus = FakeAudioFocus(
requestAudioFocusResult = requestAudioFocusResult,
)
val player = FakeSimplePlayer( val player = FakeSimplePlayer(
playLambda = playLambda, playLambda = playLambda,
prepareLambda = prepareLambda, prepareLambda = prepareLambda,
@ -161,6 +183,7 @@ class DefaultMediaPlayerTest {
) )
val sut = createDefaultMediaPlayer( val sut = createDefaultMediaPlayer(
simplePlayer = player, simplePlayer = player,
audioFocus = audioFocus,
) )
sut.state.test { sut.state.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -182,6 +205,7 @@ class DefaultMediaPlayerTest {
) )
prepareLambda.assertions().isCalledOnce() prepareLambda.assertions().isCalledOnce()
playLambda.assertions().isCalledOnce() playLambda.assertions().isCalledOnce()
requestAudioFocusResult.assertions().isCalledOnce()
} }
} }
@ -395,8 +419,10 @@ class DefaultMediaPlayerTest {
private fun TestScope.createDefaultMediaPlayer( private fun TestScope.createDefaultMediaPlayer(
simplePlayer: SimplePlayer = FakeSimplePlayer(), simplePlayer: SimplePlayer = FakeSimplePlayer(),
audioFocus: AudioFocus = FakeAudioFocus(),
): DefaultMediaPlayer = DefaultMediaPlayer( ): DefaultMediaPlayer = DefaultMediaPlayer(
simplePlayer, player = simplePlayer,
backgroundScope, coroutineScope = backgroundScope,
audioFocus = audioFocus,
) )
} }

View file

@ -16,6 +16,7 @@ class FakeSimplePlayer(
private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() }, private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() },
private val prepareLambda: () -> Unit = { lambdaError() }, private val prepareLambda: () -> Unit = { lambdaError() },
private val playLambda: () -> Unit = { lambdaError() }, private val playLambda: () -> Unit = { lambdaError() },
private val isPlayingLambda: () -> Boolean = { lambdaError() },
private val pauseLambda: () -> Unit = { lambdaError() }, private val pauseLambda: () -> Unit = { lambdaError() },
private val seekToLambda: (Long) -> Unit = { lambdaError() }, private val seekToLambda: (Long) -> Unit = { lambdaError() },
private val releaseLambda: () -> Unit = { lambdaError() }, private val releaseLambda: () -> Unit = { lambdaError() },
@ -40,6 +41,7 @@ class FakeSimplePlayer(
override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda() override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda()
override fun prepare() = prepareLambda() override fun prepare() = prepareLambda()
override fun play() = playLambda() override fun play() = playLambda()
override fun isPlaying() = isPlayingLambda()
override fun pause() = pauseLambda() override fun pause() = pauseLambda()
override fun seekTo(positionMs: Long) = seekToLambda(positionMs) override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
override fun release() = releaseLambda() override fun release() = releaseLambda()

View file

@ -36,6 +36,7 @@ dependencies {
implementation(projects.features.viewfolder.api) implementation(projects.features.viewfolder.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.di) implementation(projects.libraries.di)

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.viewfolder.api.TextFileViewer 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.di.AppScope
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@ -23,6 +24,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultLocalMediaRenderer @Inject constructor( class DefaultLocalMediaRenderer @Inject constructor(
private val textFileViewer: TextFileViewer, private val textFileViewer: TextFileViewer,
private val audioFocus: AudioFocus,
) : LocalMediaRenderer { ) : LocalMediaRenderer {
@Composable @Composable
override fun Render(localMedia: LocalMedia) { override fun Render(localMedia: LocalMedia) {
@ -37,7 +39,8 @@ class DefaultLocalMediaRenderer @Inject constructor(
localMedia = localMedia, localMedia = localMedia,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
textFileViewer = textFileViewer, textFileViewer = textFileViewer,
onClick = {} audioFocus = audioFocus,
onClick = {},
) )
} }
} }

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.local
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.features.viewfolder.api.TextFileViewer 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
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage 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( fun LocalMediaView(
localMedia: LocalMedia?, localMedia: LocalMedia?,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
audioFocus: AudioFocus?,
onClick: () -> Unit, onClick: () -> Unit,
textFileViewer: TextFileViewer, textFileViewer: TextFileViewer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -49,6 +51,7 @@ fun LocalMediaView(
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia, localMedia = localMedia,
autoplay = isUserSelected, autoplay = isUserSelected,
audioFocus = audioFocus,
modifier = modifier, modifier = modifier,
) )
mimeType == MimeTypes.PlainText -> TextFileView( mimeType == MimeTypes.PlainText -> TextFileView(
@ -68,6 +71,7 @@ fun LocalMediaView(
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia, localMedia = localMedia,
info = mediaInfo, info = mediaInfo,
audioFocus = audioFocus,
modifier = modifier, modifier = modifier,
) )
else -> MediaFileView( else -> MediaFileView(

View file

@ -52,6 +52,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -80,6 +81,7 @@ fun MediaAudioView(
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
localMedia: LocalMedia?, localMedia: LocalMedia?,
info: MediaInfo?, info: MediaInfo?,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDisplayed: Boolean = true, isDisplayed: Boolean = true,
) { ) {
@ -91,6 +93,7 @@ fun MediaAudioView(
exoPlayer = exoPlayer, exoPlayer = exoPlayer,
localMedia = localMedia, localMedia = localMedia,
info = info, info = info,
audioFocus = audioFocus,
modifier = modifier, modifier = modifier,
) )
} }
@ -104,6 +107,7 @@ private fun ExoPlayerMediaAudioView(
exoPlayer: ExoPlayer, exoPlayer: ExoPlayer,
localMedia: LocalMedia?, localMedia: LocalMedia?,
info: MediaInfo?, info: MediaInfo?,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember { var mediaPlayerControllerState: MediaPlayerControllerState by remember {
@ -294,6 +298,7 @@ private fun ExoPlayerMediaAudioView(
onToggleMute = { onToggleMute = {
// Cannot happen for audio files // Cannot happen for audio files
}, },
audioFocus = audioFocus,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@ -369,6 +374,7 @@ internal fun MediaAudioViewPreview(
bottomPaddingInPixels = 0, bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(), localMediaViewState = rememberLocalMediaViewState(),
info = info, info = info,
audioFocus = null,
localMedia = null, localMedia = null,
) )
} }

View file

@ -19,9 +19,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -32,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
@Composable @Composable
fun MediaPlayerControllerView( fun MediaPlayerControllerView(
@ -48,8 +53,26 @@ fun MediaPlayerControllerView(
onTogglePlay: () -> Unit, onTogglePlay: () -> Unit,
onSeekChange: (Float) -> Unit, onSeekChange: (Float) -> Unit,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, 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( AnimatedVisibility(
visible = state.isVisible, visible = state.isVisible,
modifier = modifier, modifier = modifier,
@ -167,5 +190,6 @@ internal fun MediaPlayerControllerViewPreview(
onTogglePlay = {}, onTogglePlay = {},
onSeekChange = {}, onSeekChange = {},
onToggleMute = {}, onToggleMute = {},
audioFocus = null,
) )
} }

View file

@ -37,6 +37,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
@ -63,6 +64,7 @@ fun MediaVideoView(
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
localMedia: LocalMedia?, localMedia: LocalMedia?,
autoplay: Boolean, autoplay: Boolean,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val exoPlayer = rememberExoPlayer() val exoPlayer = rememberExoPlayer()
@ -73,6 +75,7 @@ fun MediaVideoView(
exoPlayer = exoPlayer, exoPlayer = exoPlayer,
localMedia = localMedia, localMedia = localMedia,
autoplay = autoplay, autoplay = autoplay,
audioFocus = audioFocus,
modifier = modifier, modifier = modifier,
) )
} }
@ -86,6 +89,7 @@ private fun ExoPlayerMediaVideoView(
exoPlayer: ExoPlayer, exoPlayer: ExoPlayer,
localMedia: LocalMedia?, localMedia: LocalMedia?,
autoplay: Boolean, autoplay: Boolean,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember { var mediaPlayerControllerState: MediaPlayerControllerState by remember {
@ -248,6 +252,7 @@ private fun ExoPlayerMediaVideoView(
autoHideController++ autoHideController++
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
}, },
audioFocus = audioFocus,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@ -278,6 +283,7 @@ internal fun MediaVideoViewPreview() = ElementPreview {
bottomPaddingInPixels = 0, bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(), localMediaViewState = rememberLocalMediaViewState(),
localMedia = null, localMedia = null,
audioFocus = null,
autoplay = false, autoplay = false,
) )
} }

View file

@ -19,6 +19,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.inputs 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -44,6 +45,7 @@ class MediaViewerNode @AssistedInject constructor(
systemClock: SystemClock, systemClock: SystemClock,
pagerKeysHandler: PagerKeysHandler, pagerKeysHandler: PagerKeysHandler,
private val textFileViewer: TextFileViewer, private val textFileViewer: TextFileViewer,
private val audioFocus: AudioFocus,
) : Node(buildContext, plugins = plugins), ) : Node(buildContext, plugins = plugins),
MediaViewerNavigator { MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>() private val inputs = inputs<MediaViewerEntryPoint.Params>()
@ -129,7 +131,8 @@ class MediaViewerNode @AssistedInject constructor(
state = state, state = state,
textFileViewer = textFileViewer, textFileViewer = textFileViewer,
modifier = modifier, modifier = modifier,
onBackClick = ::onDone audioFocus = audioFocus,
onBackClick = ::onDone,
) )
} }
} }

View file

@ -51,6 +51,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.AsyncData 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
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.components.async.AsyncFailure
@ -91,6 +92,7 @@ fun MediaViewerView(
state: MediaViewerState, state: MediaViewerState,
textFileViewer: TextFileViewer, textFileViewer: TextFileViewer,
onBackClick: () -> Unit, onBackClick: () -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -163,6 +165,7 @@ fun MediaViewerView(
onShowOverlayChange = { onShowOverlayChange = {
showOverlay = it showOverlay = it
}, },
audioFocus = audioFocus,
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId, isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
) )
// Bottom bar // Bottom bar
@ -284,6 +287,7 @@ private fun MediaViewerPage(
onRetry: () -> Unit, onRetry: () -> Unit,
onDismissError: () -> Unit, onDismissError: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit, onShowOverlayChange: (Boolean) -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentShowOverlay by rememberUpdatedState(showOverlay) val currentShowOverlay by rememberUpdatedState(showOverlay)
@ -336,6 +340,7 @@ private fun MediaViewerPage(
} }
}, },
isUserSelected = isUserSelected, isUserSelected = isUserSelected,
audioFocus = audioFocus,
) )
ThumbnailView( ThumbnailView(
mediaInfo = data.mediaInfo, mediaInfo = data.mediaInfo,
@ -578,7 +583,8 @@ private fun ErrorView(
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
MediaViewerView( MediaViewerView(
state = state, state = state,
audioFocus = null,
textFileViewer = { _, _ -> }, textFileViewer = { _, _ -> },
onBackClick = {} onBackClick = {},
) )
} }

View file

@ -252,6 +252,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
setContent { setContent {
MediaViewerView( MediaViewerView(
state = state, state = state,
audioFocus = null,
textFileViewer = { _, _ -> }, textFileViewer = { _, _ -> },
onBackClick = onBackClick, onBackClick = onBackClick,
) )

View file

@ -19,6 +19,7 @@ setupAnvil()
dependencies { dependencies {
api(projects.libraries.voiceplayer.api) api(projects.libraries.voiceplayer.api)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)

View file

@ -68,7 +68,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:eventformatter:impl")) implementation(project(":libraries:eventformatter:impl"))
implementation(project(":libraries:indicator:impl")) implementation(project(":libraries:indicator:impl"))
implementation(project(":libraries:permissions:impl")) implementation(project(":libraries:permissions:impl"))
implementation(project(":libraries:push:impl")) implementation(project(":libraries:audio:impl"))
implementation(project(":libraries:push:impl")) implementation(project(":libraries:push:impl"))
implementation(project(":libraries:featureflag:impl")) implementation(project(":libraries:featureflag:impl"))
implementation(project(":libraries:pushstore:impl")) implementation(project(":libraries:pushstore:impl"))