Add custom video player controller
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<MediaPlayerControllerState> {
|
||||
override val values: Sequence<MediaPlayerControllerState> = 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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user