From 70f086b5a17bdfef46d79701552835180a72ad5b Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 13 May 2026 23:05:10 +0200 Subject: [PATCH] feat: enhance player UI with hidden seek timeline and time display improvements --- .../ui/screen/player/TvPlayerScreen.kt | 88 ++++++++++++++++++- .../components/TvPlayerControlsOverlay.kt | 50 +++++++---- .../purefin/ui/screen/player/PlayerScreen.kt | 84 +++++++++++++++++- .../components/PlayerControlsOverlay.kt | 85 +++++++++++------- .../player/components/PlayerGesturesLayer.kt | 11 ++- 5 files changed, 257 insertions(+), 61 deletions(-) diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt index d3704093..53c3bf36 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt @@ -12,11 +12,15 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight 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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.SkipNext @@ -53,9 +57,12 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView +import hu.bbara.purefin.core.player.model.TimedMarker import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel +import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack import hu.bbara.purefin.ui.screen.player.components.TvIconButton +import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType @@ -85,6 +92,8 @@ fun TvPlayerScreen( var pendingTrackButtonFocus by remember { mutableStateOf(null) } var stopFeedbackVisible by remember { mutableStateOf(false) } var stopFeedbackRequestId by remember { mutableStateOf(0) } + var hiddenSeekPreviewPositionMs by remember { mutableStateOf(null) } + var hiddenSeekRequestId by remember { mutableStateOf(0) } val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null val context = LocalContext.current @@ -162,6 +171,9 @@ fun TvPlayerScreen( showTvControls() } val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs -> + val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs + hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs) + hiddenSeekRequestId += 1 viewModel.seekBy(deltaMs) stopFeedbackVisible = false } @@ -221,9 +233,16 @@ fun TvPlayerScreen( stopFeedbackVisible = false } + LaunchedEffect(hiddenSeekRequestId) { + if (hiddenSeekRequestId == 0) return@LaunchedEffect + delay(TV_HIDDEN_STOP_FEEDBACK_MS) + hiddenSeekPreviewPositionMs = null + } + LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) { if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) { stopFeedbackVisible = false + hiddenSeekPreviewPositionMs = null } } @@ -330,7 +349,10 @@ fun TvPlayerScreen( exit = fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 24.dp) + .padding( + end = 24.dp, + bottom = if (hiddenSeekPreviewPositionMs != null) 104.dp else 24.dp + ) ) { TvIconButton( icon = Icons.Outlined.SkipNext, @@ -342,6 +364,23 @@ fun TvPlayerScreen( ) } + AnimatedVisibility( + visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 32.dp, vertical = 28.dp) + ) { + HiddenTvSeekTimeline( + positionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs, + durationMs = uiState.durationMs, + bufferedMs = uiState.bufferedMs, + chapterMarkers = uiState.chapters, + adMarkers = uiState.ads + ) + } + TvPlayerLoadingErrorEndCard( modifier = Modifier.align(Alignment.Center), uiState = uiState, @@ -384,6 +423,53 @@ fun TvPlayerScreen( } } +@Composable +private fun HiddenTvSeekTimeline( + positionMs: Long, + durationMs: Long, + bufferedMs: Long, + chapterMarkers: List, + adMarkers: List, + modifier: Modifier = Modifier +) { + val safeDuration = durationMs.takeIf { it > 0 } ?: 1L + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + TvPlayerTimeRow( + positionMs = positionMs, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth() + ) + PlayerSeekBarTrack( + positionMs = positionMs, + durationMs = safeDuration, + bufferedMs = bufferedMs, + chapterMarkers = chapterMarkers, + adMarkers = adMarkers, + modifier = Modifier + .fillMaxWidth() + .height(32.dp), + isFocused = false, + thumbRadius = 7.dp, + focusedThumbRadius = 9.dp, + focusedThumbHaloRadiusDelta = 0.dp + ) + } +} + +private fun Long.coerceSeekPosition(durationMs: Long): Long = + if (durationMs > 0L) { + coerceIn(0L, durationMs) + } else { + coerceAtLeast(0L) + } + internal fun handleTvPlayerRootKeyEvent( event: androidx.compose.ui.input.key.KeyEvent, isPlaying: Boolean, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt index f5b99024..00554668 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt @@ -187,7 +187,6 @@ private fun TvPlayerBottomSection( subtitlesButtonEnabled: Boolean, modifier: Modifier = Modifier ) { - val scheme = MaterialTheme.colorScheme val playlistFocusRequester = remember { FocusRequester() } var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) } @@ -210,24 +209,11 @@ private fun TvPlayerBottomSection( .testTag(TvPlayerPlaylistStateTag) .semantics { stateDescription = playlistExpandState } ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatTime(uiState.positionMs), - color = scheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = formatTime(uiState.durationMs), - color = scheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - } + TvPlayerTimeRow( + positionMs = uiState.positionMs, + durationMs = uiState.durationMs, + modifier = Modifier.padding(horizontal = 4.dp) + ) TvPlayerSeekBar( positionMs = uiState.positionMs, durationMs = uiState.durationMs, @@ -355,6 +341,32 @@ private fun TvPlayerBottomSection( } } +@Composable +internal fun TvPlayerTimeRow( + positionMs: Long, + durationMs: Long, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatTime(positionMs), + color = scheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = formatTime(durationMs), + color = scheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + } +} + private fun handleExpandPlaylistKey( event: androidx.compose.ui.input.key.KeyEvent, onExpand: () -> Unit diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt index 05d368a4..83b74827 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt @@ -11,8 +11,11 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -40,6 +43,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView +import hu.bbara.purefin.core.player.model.TimedMarker import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility @@ -49,6 +53,8 @@ import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel +import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack +import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController import kotlin.math.abs @@ -73,6 +79,7 @@ fun PlayerScreen( var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var showQueuePanel by remember { mutableStateOf(false) } var horizontalSeekFeedback by remember { mutableStateOf(null) } + var horizontalSeekPreviewPositionMs by remember { mutableStateOf(null) } val overlayController = rememberPersistentOverlayController() LifecycleEventEffect(Lifecycle.Event.ON_STOP) { @@ -136,8 +143,15 @@ fun PlayerScreen( 0 ) }, - onHorizontalDragPreview = { - horizontalSeekFeedback = it + onHorizontalDragPreview = { deltaMs, previewPositionMs -> + horizontalSeekFeedback = deltaMs + horizontalSeekPreviewPositionMs = previewPositionMs?.let { + if (uiState.durationMs > 0L) { + it.coerceIn(0L, uiState.durationMs) + } else { + it.coerceAtLeast(0L) + } + } }, onHorizontalDragSeekTo = { viewModel.seekTo(it) @@ -156,6 +170,25 @@ fun PlayerScreen( ) } + EmptyValueTimedVisibility( + value = horizontalSeekPreviewPositionMs, + hideAfterMillis = 1_000 + ) { previewPositionMs -> + if (!playerControlsVisible) { + HiddenSeekTimeline( + positionMs = previewPositionMs, + durationMs = uiState.durationMs, + bufferedMs = uiState.bufferedMs, + chapterMarkers = uiState.chapters, + adMarkers = uiState.ads, + isLive = uiState.isLive, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 24.dp, vertical = 28.dp) + ) + } + } + ValueChangeTimedVisibility( value = brightness, hideAfterMillis = 800, @@ -218,7 +251,10 @@ fun PlayerScreen( exit = fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 24.dp, bottom = 24.dp) + .padding( + end = 24.dp, + bottom = if (horizontalSeekPreviewPositionMs != null) 96.dp else 24.dp + ) ) { SkipSegmentButton(onClick = { viewModel.skipActiveSegment() }) } @@ -258,6 +294,48 @@ fun PlayerScreen( } } +@Composable +private fun HiddenSeekTimeline( + positionMs: Long, + durationMs: Long, + bufferedMs: Long, + chapterMarkers: List, + adMarkers: List, + isLive: Boolean, + modifier: Modifier = Modifier +) { + val safeDuration = durationMs.takeIf { it > 0 } ?: 1L + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + PlayerTimeRow( + positionMs = positionMs, + durationMs = durationMs, + isLive = isLive, + onSeekLiveEdge = null + ) + PlayerSeekBarTrack( + positionMs = positionMs, + durationMs = safeDuration, + bufferedMs = bufferedMs, + chapterMarkers = chapterMarkers, + adMarkers = adMarkers, + modifier = Modifier + .fillMaxWidth() + .height(28.dp), + isFocused = false, + focusedTrackHeight = 4.dp, + focusedThumbRadius = 6.dp, + focusedThumbHaloRadiusDelta = 0.dp + ) + } +} + @Composable private fun SeekAmountIndicator(deltaMs: Long, modifier: Modifier = Modifier) { val scheme = MaterialTheme.colorScheme diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt index 72353ca4..83967f15 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt @@ -65,7 +65,6 @@ fun PlayerControlsOverlay( onOpenQueue: () -> Unit, modifier: Modifier = Modifier ) { - val scheme = MaterialTheme.colorScheme var scrubbing by remember { mutableStateOf(false) } Box( @@ -177,40 +176,14 @@ private fun BottomSection( onQueueSelected: (String) -> Unit, modifier: Modifier = Modifier ) { - val scheme = MaterialTheme.colorScheme Column(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatTime(uiState.positionMs), - color = scheme.onSurface, - style = MaterialTheme.typography.bodySmall - ) - if (uiState.isLive) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold) - Text( - text = "Catch up", - color = scheme.onSurface, - modifier = Modifier.clickable { onSeekLiveEdge() } - ) - } - } else { - Text( - text = formatTime(uiState.durationMs), - color = scheme.onSurface, - style = MaterialTheme.typography.bodySmall - ) - } - } + PlayerTimeRow( + positionMs = uiState.positionMs, + durationMs = uiState.durationMs, + isLive = uiState.isLive, + onSeekLiveEdge = onSeekLiveEdge, + modifier = Modifier.padding(horizontal = 8.dp) + ) PlayerSeekBar( positionMs = uiState.positionMs, durationMs = uiState.durationMs, @@ -329,6 +302,50 @@ fun SkipSegmentButton( } } +@Composable +internal fun PlayerTimeRow( + positionMs: Long, + durationMs: Long, + isLive: Boolean, + onSeekLiveEdge: (() -> Unit)?, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatTime(positionMs), + color = scheme.onSurface, + style = MaterialTheme.typography.bodySmall + ) + if (isLive) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold) + if (onSeekLiveEdge != null) { + Text( + text = "Catch up", + color = scheme.onSurface, + modifier = Modifier.clickable { onSeekLiveEdge() } + ) + } + } + } else { + Text( + text = formatTime(durationMs), + color = scheme.onSurface, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + private fun formatTime(positionMs: Long): String { val totalSeconds = positionMs / 1000 val seconds = (totalSeconds % 60).toInt() diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerGesturesLayer.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerGesturesLayer.kt index 557fa47b..5f5d02bf 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerGesturesLayer.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerGesturesLayer.kt @@ -28,7 +28,7 @@ fun PlayerGesturesLayer( onDoubleTapLeft: () -> Unit, onVerticalDragLeft: (delta: Float) -> Unit, onVerticalDragRight: (delta: Float) -> Unit, - onHorizontalDragPreview: (deltaMs: Long?) -> Unit = {}, + onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> }, onHorizontalDragSeekTo: (positionMs: Long) -> Unit, currentPositionProvider: () -> Long, ) { @@ -95,7 +95,10 @@ fun PlayerGesturesLayer( val deltaMs = HorizontalSeekGestureHelper.deltaMs(accumulatedHorizontalDrag) if (deltaMs != 0L && deltaMs != lastPreviewDelta) { lastPreviewDelta = deltaMs - onHorizontalDragPreview(deltaMs) + onHorizontalDragPreview( + deltaMs, + (startPositionMs + deltaMs).coerceAtLeast(0L) + ) } } } @@ -117,10 +120,10 @@ fun PlayerGesturesLayer( if (deltaMs != 0L) { val targetMs = (startPositionMs + deltaMs).coerceAtLeast(0L) onHorizontalDragSeekTo(targetMs) - onHorizontalDragPreview(deltaMs) + onHorizontalDragPreview(deltaMs, targetMs) } } - onHorizontalDragPreview(null) + onHorizontalDragPreview(null, null) } } )