integrate custom controls for player

uniform UI/UX and topbar visibility state is not detached from controls visibility
This commit is contained in:
Marius Butz 2026-05-04 12:42:59 +02:00
parent 504ed2350a
commit 2b6b0dd57f

View file

@ -5,24 +5,31 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close 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.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -35,10 +42,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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.EnterImmersivePlayerMode
import com.nuvio.app.features.player.LockPlayerToLandscape import com.nuvio.app.features.player.LockPlayerToLandscape
import com.nuvio.app.features.player.PlatformPlayerSurface 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.PlayerResizeMode
import com.nuvio.app.features.player.formatPlaybackTime
import com.nuvio.app.features.player.playerHorizontalSafePadding
import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.trailer.TrailerPlaybackSource
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_retry 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.detail_tab_trailer
import nuvio.composeapp.generated.resources.trailer_close import nuvio.composeapp.generated.resources.trailer_close
import nuvio.composeapp.generated.resources.trailer_unable_to_play import nuvio.composeapp.generated.resources.trailer_unable_to_play
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
private const val TrailerControlsAutoHideMs = 3500L private const val TrailerControlsAutoHideMs = 3500L
private const val TrailerSeekStepMs = 10_000L
@Composable @Composable
fun TrailerPlayer( fun TrailerPlayer(
@ -125,26 +141,34 @@ private fun FullscreenTrailerPlayerContent(
} }
val activeError = errorMessage ?: playerError val activeError = errorMessage ?: playerError
var playerController by remember(playbackSource?.videoUrl) {
mutableStateOf<PlayerEngineController?>(null)
}
var playbackSnapshot by remember(playbackSource?.videoUrl) {
mutableStateOf(PlayerPlaybackSnapshot())
}
var scrubbingPositionMs by remember(playbackSource?.videoUrl) {
mutableStateOf<Long?>(null)
}
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
var controlsVisible by remember { mutableStateOf(false) } var controlsVisible by remember { mutableStateOf(false) }
LaunchedEffect(controlsVisible, isLoading, activeError, playbackSource) { LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, isLoading, activeError, playbackSource) {
if (!controlsVisible || isLoading || activeError != null || playbackSource == null) { if (!controlsVisible || !playbackSnapshot.isPlaying || isLoading || activeError != null || playbackSource == null) {
return@LaunchedEffect return@LaunchedEffect
} }
delay(TrailerControlsAutoHideMs) delay(TrailerControlsAutoHideMs)
controlsVisible = false controlsVisible = false
} }
Box( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black),
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
controlsVisible = !controlsVisible
}
},
) { ) {
val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) }
val horizontalSafePadding = playerHorizontalSafePadding()
when { when {
isLoading -> { isLoading -> {
CircularProgressIndicator( CircularProgressIndicator(
@ -182,18 +206,32 @@ private fun FullscreenTrailerPlayerContent(
} }
playbackSource != null -> { playbackSource != null -> {
PlatformPlayerSurface( Box(
sourceUrl = playbackSource.videoUrl, modifier = Modifier
sourceAudioUrl = playbackSource.audioUrl, .fillMaxSize()
useYoutubeChunkedPlayback = true, .pointerInput(Unit) {
modifier = Modifier.fillMaxSize(), detectTapGestures(onTap = { controlsVisible = !controlsVisible })
playWhenReady = true, },
resizeMode = PlayerResizeMode.Fit, ) {
useNativeController = true, PlatformPlayerSurface(
onControllerReady = {}, sourceUrl = playbackSource.videoUrl,
onSnapshot = {}, sourceAudioUrl = playbackSource.audioUrl,
onError = { playerError = it }, 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, visible = controlsVisible || isLoading || activeError != null,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.fillMaxSize(),
) { ) {
TrailerTopBar( ControlsOverlay(
title = headerType, title = headerType,
subtitle = headerSubtitle, subtitle = headerSubtitle,
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
horizontalSafePadding = horizontalSafePadding,
hasPlaybackSurface = playbackSource != null && activeError == null,
onClose = onDismiss, 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 @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, title: String,
subtitle: String, subtitle: String,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background( .background(
Color.Black.copy(alpha = 0.55f), 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,
)
}
}