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

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