From c46c54efa2a2e78ccaa02efccbf39139ef95f410 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 27 Nov 2024 16:00:28 +0100 Subject: [PATCH] Add custom video player controller --- libraries/mediaviewer/api/build.gradle.kts | 1 + .../mediaviewer/api/local/LocalMediaView.kt | 157 +++++++++++++----- .../api/local/LocalMediaViewState.kt | 5 +- .../api/player/MediaPlayerControllerState.kt | 15 ++ .../MediaPlayerControllerStateProvider.kt | 55 ++++++ .../api/player/MediaPlayerControllerView.kt | 151 +++++++++++++++++ 6 files changed, 344 insertions(+), 40 deletions(-) create mode 100644 libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerState.kt create mode 100644 libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerStateProvider.kt create mode 100644 libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerView.kt diff --git a/libraries/mediaviewer/api/build.gradle.kts b/libraries/mediaviewer/api/build.gradle.kts index d55528bbe3..5d434ae094 100644 --- a/libraries/mediaviewer/api/build.gradle.kts +++ b/libraries/mediaviewer/api/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) + implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.di) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt index 7b63c22121..b644ebc685 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.api.local import android.annotation.SuppressLint import android.net.Uri -import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.compose.foundation.Image @@ -19,6 +18,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -30,6 +31,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -49,6 +51,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import io.element.android.compound.theme.ElementTheme @@ -67,9 +70,13 @@ import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAn import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWrapper import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState +import io.element.android.libraries.mediaviewer.api.player.MediaPlayerControllerState +import io.element.android.libraries.mediaviewer.api.player.MediaPlayerControllerView import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.delay import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState +import kotlin.time.Duration.Companion.seconds @Composable fun LocalMediaView( @@ -91,7 +98,6 @@ fun LocalMediaView( localMediaViewState = localMediaViewState, localMedia = localMedia, modifier = modifier, - onClick = onClick, ) mimeType == MimeTypes.Pdf -> MediaPDFView( localMediaViewState = localMediaViewState, @@ -141,7 +147,6 @@ private fun MediaImageView( private fun MediaVideoView( localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, - onClick: () -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -155,7 +160,6 @@ private fun MediaVideoView( ExoPlayerMediaVideoView( localMediaViewState = localMediaViewState, localMedia = localMedia, - onClick = onClick, modifier = modifier, ) } @@ -166,15 +170,25 @@ private fun MediaVideoView( private fun ExoPlayerMediaVideoView( localMediaViewState: LocalMediaViewState, localMedia: LocalMedia?, - onClick: () -> Unit, modifier: Modifier = Modifier, ) { var playableState: PlayableState.Playable by remember { - mutableStateOf(PlayableState.Playable(isPlaying = false, isShowingControls = false)) + mutableStateOf( + PlayableState.Playable( + isPlaying = false, + progressInMillis = 0, + durationInMillis = 0, + isShowingControls = false, + isMuted = false, + ) + ) } localMediaViewState.playableState = playableState val context = LocalContext.current + val exoPlayer = remember { + ExoPlayerWrapper.create(context) + } val playerListener = object : Player.Listener { override fun onRenderedFirstFrame() { localMediaViewState.isReady = true @@ -183,13 +197,48 @@ private fun ExoPlayerMediaVideoView( override fun onIsPlayingChanged(isPlaying: Boolean) { playableState = playableState.copy(isPlaying = isPlaying) } - } - val exoPlayer = remember { - ExoPlayerWrapper.create(context) - .apply { - addListener(playerListener) - this.prepare() + + override fun onVolumeChanged(volume: Float) { + playableState = playableState.copy(isMuted = volume == 0f) + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + playableState = playableState.copy( + durationInMillis = exoPlayer.duration, + ) } + } + } + + LaunchedEffect(Unit) { + exoPlayer.addListener(playerListener) + exoPlayer.prepare() + } + + var autoHideController by remember { mutableIntStateOf(0) } + + LaunchedEffect(autoHideController) { + delay(5.seconds) + if (exoPlayer.isPlaying) { + playableState = playableState.copy(isShowingControls = false) + } + } + + LaunchedEffect(exoPlayer.isPlaying) { + if (exoPlayer.isPlaying) { + while (true) { + playableState = playableState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + delay(200) + } + } else { + // Ensure we render the final state + playableState = playableState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + } } if (localMedia?.uri != null) { LaunchedEffect(localMedia.uri) { @@ -200,34 +249,64 @@ private fun ExoPlayerMediaVideoView( exoPlayer.setMediaItems(emptyList()) } KeepScreenOn(playableState.isPlaying) - AndroidView( - factory = { - PlayerView(context).apply { - player = exoPlayer - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - setOnClickListener { - onClick() - } - setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility -> - val isShowingControls = visibility == View.VISIBLE - playableState = playableState.copy(isShowingControls = isShowingControls) - }) - controllerShowTimeoutMs = 1500 - setShowPreviousButton(false) - setShowFastForwardButton(false) - setShowRewindButton(false) - setShowNextButton(false) - showController() - } - }, - onRelease = { playerView -> - playerView.setOnClickListener(null) - playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?) - playerView.player = null - }, + Box( modifier = modifier - ) + .background(ElementTheme.colors.bgSubtlePrimary) + .wrapContentSize(), + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + PlayerView(context).apply { + player = exoPlayer + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + setOnClickListener { + autoHideController++ + playableState = playableState.copy(isShowingControls = !playableState.isShowingControls) + } + useController = false + } + }, + onRelease = { playerView -> + playerView.setOnClickListener(null) + playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?) + playerView.player = null + }, + ) + MediaPlayerControllerView( + state = MediaPlayerControllerState( + isVisible = playableState.isShowingControls, + playableState = playableState, + ), + onTogglePlay = { + autoHideController++ + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + if (exoPlayer.playbackState == Player.STATE_ENDED) { + exoPlayer.seekTo(0) + } else { + exoPlayer.play() + } + } + }, + onSeekChange = { + autoHideController++ + if (exoPlayer.isPlaying.not()) { + exoPlayer.play() + } + exoPlayer.seekTo(it.toLong()) + }, + onToggleMute = { + autoHideController++ + exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f + }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } OnLifecycleEvent { _, event -> when (event) { diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt index 0c4e2af308..54eb3f0970 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt @@ -30,7 +30,10 @@ sealed interface PlayableState { data object NotPlayable : PlayableState data class Playable( val isPlaying: Boolean, - val isShowingControls: Boolean + val progressInMillis: Long, + val durationInMillis: Long, + val isShowingControls: Boolean, + val isMuted: Boolean, ) : PlayableState } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerState.kt new file mode 100644 index 0000000000..b6963a16b1 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerState.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.player + +import io.element.android.libraries.mediaviewer.api.local.PlayableState + +data class MediaPlayerControllerState( + val isVisible: Boolean, + val playableState: PlayableState.Playable, +) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerStateProvider.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerStateProvider.kt new file mode 100644 index 0000000000..64b8ef241e --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerStateProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.player + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.mediaviewer.api.local.PlayableState + +open class MediaPlayerControllerStateProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aMediaPlayerControllerState(), + aMediaPlayerControllerState( + isPlaying = false, + progressInMillis = 59_000, + durationInMillis = 83_000, + isMuted = true, + ), + ) +} + +private fun aMediaPlayerControllerState( + isVisible: Boolean = true, + isPlaying: Boolean = false, + progressInMillis: Long = 0, + // Default to 1 minute and 23 seconds + durationInMillis: Long = 83_000, + isMuted: Boolean = false, +) = MediaPlayerControllerState( + isVisible = isVisible, + playableState = aPlayableState( + isPlaying = isPlaying, + progressInMillis = progressInMillis, + durationInMillis = durationInMillis, + isMuted = isMuted, + ), +) + +private fun aPlayableState( + isPlaying: Boolean = false, + progressInMillis: Long = 0, + // Default to 1 minute and 23 seconds + durationInMillis: Long = 83_000, + isShowingControls: Boolean = false, + isMuted: Boolean = false, +) = PlayableState.Playable( + isPlaying = isPlaying, + progressInMillis = progressInMillis, + durationInMillis = durationInMillis, + isShowingControls = isShowingControls, + isMuted = isMuted, +) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerView.kt new file mode 100644 index 0000000000..b3c25edbdb --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/player/MediaPlayerControllerView.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.dateformatter.api.toHumanReadableDuration +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Slider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun MediaPlayerControllerView( + state: MediaPlayerControllerState, + onTogglePlay: () -> Unit, + onSeekChange: (Float) -> Unit, + onToggleMute: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = state.isVisible, + modifier = modifier, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .background(color = Color(0x99101317)) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .widthIn(max = 480.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onTogglePlay, + ) { + if (state.playableState.isPlaying) { + Icon( + imageVector = CompoundIcons.PauseSolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.a11y_pause) + ) + } else { + Icon( + imageVector = CompoundIcons.PlaySolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.a11y_play) + ) + } + } + Text( + modifier = Modifier + .widthIn(min = 48.dp) + .padding(horizontal = 8.dp), + text = state.playableState.progressInMillis.toHumanReadableDuration(), + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + var lastSelectedValue by remember { mutableFloatStateOf(-1f) } + Slider( + modifier = Modifier.weight(1f), + valueRange = 0f..state.playableState.durationInMillis.toFloat(), + value = lastSelectedValue.takeIf { it >= 0 } ?: state.playableState.progressInMillis.toFloat(), + onValueChange = { + lastSelectedValue = it + }, + onValueChangeFinish = { + onSeekChange(lastSelectedValue) + lastSelectedValue = -1f + }, + useCustomLayout = true, + ) + val formattedDuration = remember(state.playableState.durationInMillis) { + state.playableState.durationInMillis.toHumanReadableDuration() + } + Text( + modifier = Modifier + .widthIn(min = 48.dp) + .padding(horizontal = 8.dp), + text = formattedDuration, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + IconButton( + onClick = onToggleMute, + ) { + if (state.playableState.isMuted) { + Icon( + imageVector = CompoundIcons.VolumeOffSolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.common_unmute) + ) + } else { + Icon( + imageVector = CompoundIcons.VolumeOnSolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.common_mute) + ) + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +private fun MediaPlayerControlBarPreview( + @PreviewParameter(MediaPlayerControllerStateProvider::class) state: MediaPlayerControllerState +) = ElementPreview { + MediaPlayerControllerView( + state = state, + onTogglePlay = {}, + onSeekChange = {}, + onToggleMute = {}, + ) +}