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 e6b1c196d6
commit 0255111528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 341 additions and 49 deletions

View file

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

View file

@ -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<CallScreenState>
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<CallType?>(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())

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

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

View file

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

View file

@ -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 = {},
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MediaViewerEntryPoint.Params>()
@ -129,7 +131,8 @@ class MediaViewerNode @AssistedInject constructor(
state = state,
textFileViewer = textFileViewer,
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.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 = {},
)
}

View file

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

View file

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

View file

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