diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayer.kt index 1734c7e2..dcd6ec60 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayer.kt @@ -5,24 +5,31 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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 +import androidx.compose.foundation.border import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -35,10 +42,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -46,17 +54,25 @@ import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.player.EnterImmersivePlayerMode import com.nuvio.app.features.player.LockPlayerToLandscape import com.nuvio.app.features.player.PlatformPlayerSurface +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerLayoutMetrics +import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.player.PlayerResizeMode +import com.nuvio.app.features.player.formatPlaybackTime +import com.nuvio.app.features.player.playerHorizontalSafePadding import com.nuvio.app.features.trailer.TrailerPlaybackSource import kotlinx.coroutines.delay import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_retry +import nuvio.composeapp.generated.resources.compose_player_seek_back_10 +import nuvio.composeapp.generated.resources.compose_player_seek_forward_10 import nuvio.composeapp.generated.resources.detail_tab_trailer import nuvio.composeapp.generated.resources.trailer_close import nuvio.composeapp.generated.resources.trailer_unable_to_play import org.jetbrains.compose.resources.stringResource private const val TrailerControlsAutoHideMs = 3500L +private const val TrailerSeekStepMs = 10_000L @Composable fun TrailerPlayer( @@ -125,26 +141,34 @@ private fun FullscreenTrailerPlayerContent( } val activeError = errorMessage ?: playerError + var playerController by remember(playbackSource?.videoUrl) { + mutableStateOf(null) + } + var playbackSnapshot by remember(playbackSource?.videoUrl) { + mutableStateOf(PlayerPlaybackSnapshot()) + } + var scrubbingPositionMs by remember(playbackSource?.videoUrl) { + mutableStateOf(null) + } + val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs + var controlsVisible by remember { mutableStateOf(false) } - LaunchedEffect(controlsVisible, isLoading, activeError, playbackSource) { - if (!controlsVisible || isLoading || activeError != null || playbackSource == null) { + LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, isLoading, activeError, playbackSource) { + if (!controlsVisible || !playbackSnapshot.isPlaying || isLoading || activeError != null || playbackSource == null) { return@LaunchedEffect } delay(TrailerControlsAutoHideMs) controlsVisible = false } - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(Color.Black) - .pointerInput(Unit) { - awaitEachGesture { - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - controlsVisible = !controlsVisible - } - }, + .background(Color.Black), ) { + val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) } + val horizontalSafePadding = playerHorizontalSafePadding() + when { isLoading -> { CircularProgressIndicator( @@ -182,18 +206,32 @@ private fun FullscreenTrailerPlayerContent( } playbackSource != null -> { - PlatformPlayerSurface( - sourceUrl = playbackSource.videoUrl, - sourceAudioUrl = playbackSource.audioUrl, - useYoutubeChunkedPlayback = true, - modifier = Modifier.fillMaxSize(), - playWhenReady = true, - resizeMode = PlayerResizeMode.Fit, - useNativeController = true, - onControllerReady = {}, - onSnapshot = {}, - onError = { playerError = it }, - ) + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures(onTap = { controlsVisible = !controlsVisible }) + }, + ) { + PlatformPlayerSurface( + sourceUrl = playbackSource.videoUrl, + sourceAudioUrl = playbackSource.audioUrl, + useYoutubeChunkedPlayback = true, + modifier = Modifier.fillMaxSize(), + playWhenReady = true, + resizeMode = PlayerResizeMode.Fit, + useNativeController = false, + onControllerReady = { playerController = it }, + onSnapshot = { playbackSnapshot = it }, + onError = { playerError = it }, + ) + } + if (playbackSnapshot.isLoading) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } } } @@ -201,25 +239,100 @@ private fun FullscreenTrailerPlayerContent( visible = controlsVisible || isLoading || activeError != null, enter = fadeIn(), exit = fadeOut(), - modifier = Modifier.align(Alignment.TopStart), + modifier = Modifier.fillMaxSize(), ) { - TrailerTopBar( + ControlsOverlay( title = headerType, subtitle = headerSubtitle, + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + hasPlaybackSurface = playbackSource != null && activeError == null, onClose = onDismiss, + onTogglePlayback = { + val controller = playerController ?: return@ControlsOverlay + if (playbackSnapshot.isPlaying) controller.pause() else controller.play() + }, + onSeekBack = { playerController?.seekBy(-TrailerSeekStepMs) }, + onSeekForward = { playerController?.seekBy(TrailerSeekStepMs) }, + onScrubChange = { scrubbingPositionMs = it }, + onScrubFinished = { + val controller = playerController + val target = scrubbingPositionMs + scrubbingPositionMs = null + if (controller != null && target != null) { + controller.seekTo(target) + } + }, ) } } } @Composable -private fun TrailerTopBar( +private fun ControlsOverlay( + title: String, + subtitle: String, + playbackSnapshot: PlayerPlaybackSnapshot, + displayedPositionMs: Long, + metrics: PlayerLayoutMetrics, + horizontalSafePadding: Dp, + hasPlaybackSurface: Boolean, + onClose: () -> Unit, + onTogglePlayback: () -> Unit, + onSeekBack: () -> Unit, + onSeekForward: () -> Unit, + onScrubChange: (Long) -> Unit, + onScrubFinished: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + TopBar( + title = title, + subtitle = subtitle, + onClose = onClose, + modifier = Modifier + .align(Alignment.TopStart) + .padding(horizontal = horizontalSafePadding), + ) + + if (hasPlaybackSurface) { + CenterControls( + snapshot = playbackSnapshot, + metrics = metrics, + onSeekBack = onSeekBack, + onSeekForward = onSeekForward, + onTogglePlayback = onTogglePlayback, + modifier = Modifier + .align(Alignment.Center) + .padding(bottom = metrics.centerLift), + ) + + ProgressBar( + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + onScrubChange = onScrubChange, + onScrubFinished = onScrubFinished, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = horizontalSafePadding + metrics.horizontalPadding) + .padding(bottom = metrics.sliderBottomOffset), + ) + } + } +} + +@Composable +private fun TopBar( title: String, subtitle: String, onClose: () -> Unit, + modifier: Modifier = Modifier, ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .background( Color.Black.copy(alpha = 0.55f), @@ -271,3 +384,151 @@ private fun TrailerTopBar( } } } + +@Composable +private fun ProgressBar( + playbackSnapshot: PlayerPlaybackSnapshot, + displayedPositionMs: Long, + metrics: PlayerLayoutMetrics, + onScrubChange: (Long) -> Unit, + onScrubFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) + val clampedPosition = displayedPositionMs.coerceIn(0L, durationMs) + Column(modifier = modifier) { + Slider( + modifier = Modifier + .fillMaxWidth() + .height(metrics.sliderTouchHeight) + .graphicsLayer(scaleY = metrics.sliderScaleY), + value = clampedPosition.toFloat(), + onValueChange = { onScrubChange(it.toLong()) }, + onValueChangeFinished = onScrubFinished, + valueRange = 0f..durationMs.toFloat(), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .padding(top = 4.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TimePill(text = formatPlaybackTime(clampedPosition), fontSize = metrics.timeSize) + TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize) + } + } +} + +@Composable +private fun CenterControls( + snapshot: PlayerPlaybackSnapshot, + metrics: PlayerLayoutMetrics, + onSeekBack: () -> Unit, + onSeekForward: () -> Unit, + onTogglePlayback: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(metrics.centerGap), + verticalAlignment = Alignment.CenterVertically, + ) { + SideControlButton( + icon = Icons.Rounded.Replay10, + contentDescription = stringResource(Res.string.compose_player_seek_back_10), + metrics = metrics, + onClick = onSeekBack, + ) + PlayPauseControlButton( + isPlaying = snapshot.isPlaying, + isBuffering = snapshot.isLoading, + metrics = metrics, + onClick = onTogglePlayback, + ) + SideControlButton( + icon = Icons.Rounded.Forward10, + contentDescription = stringResource(Res.string.compose_player_seek_forward_10), + metrics = metrics, + onClick = onSeekForward, + ) + } +} + +@Composable +private fun SideControlButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String?, + metrics: PlayerLayoutMetrics, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = onClick) + .padding(metrics.sideButtonPadding), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(metrics.sideIconSize), + ) + } +} + +@Composable +private fun PlayPauseControlButton( + isPlaying: Boolean, + isBuffering: Boolean, + metrics: PlayerLayoutMetrics, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = onClick) + .padding(metrics.playButtonPadding), + contentAlignment = Alignment.Center, + ) { + if (isBuffering) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 3.dp, + modifier = Modifier.size(metrics.playIconSize), + ) + } else { + Icon( + imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(metrics.playIconSize), + ) + } + } +} + +@Composable +private fun TimePill( + text: String, + fontSize: androidx.compose.ui.unit.TextUnit, +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.5f)) + .border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = fontSize, + fontWeight = FontWeight.Medium, + ), + color = Color.White, + ) + } +}