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:
parent
3cf8b04a07
commit
93499910ba
24 changed files with 341 additions and 49 deletions
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
|||
setContent {
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
audioFocus = null,
|
||||
textFileViewer = { _, _ -> },
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue