feat: enhance player UI with hidden seek timeline and time display improvements

This commit is contained in:
2026-05-13 23:05:10 +02:00
parent cc643ae34f
commit 70f086b5a1
5 changed files with 257 additions and 61 deletions

View File

@@ -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<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
var hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(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<TimedMarker>,
adMarkers: List<TimedMarker>,
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,

View File

@@ -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

View File

@@ -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<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(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<TimedMarker>,
adMarkers: List<TimedMarker>,
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

View File

@@ -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()

View File

@@ -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)
}
}
)