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.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.SkipNext 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.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView 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.ControlsAutoHideBlocker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel 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.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.TvPlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
@@ -85,6 +92,8 @@ fun TvPlayerScreen(
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) } var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) } var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) } 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 controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
val context = LocalContext.current val context = LocalContext.current
@@ -162,6 +171,9 @@ fun TvPlayerScreen(
showTvControls() showTvControls()
} }
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs -> val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs
hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs)
hiddenSeekRequestId += 1
viewModel.seekBy(deltaMs) viewModel.seekBy(deltaMs)
stopFeedbackVisible = false stopFeedbackVisible = false
} }
@@ -221,9 +233,16 @@ fun TvPlayerScreen(
stopFeedbackVisible = false 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) { LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) { if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
stopFeedbackVisible = false stopFeedbackVisible = false
hiddenSeekPreviewPositionMs = null
} }
} }
@@ -330,7 +349,10 @@ fun TvPlayerScreen(
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 24.dp) .padding(
end = 24.dp,
bottom = if (hiddenSeekPreviewPositionMs != null) 104.dp else 24.dp
)
) { ) {
TvIconButton( TvIconButton(
icon = Icons.Outlined.SkipNext, 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( TvPlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
uiState = uiState, 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( internal fun handleTvPlayerRootKeyEvent(
event: androidx.compose.ui.input.key.KeyEvent, event: androidx.compose.ui.input.key.KeyEvent,
isPlaying: Boolean, isPlaying: Boolean,

View File

@@ -187,7 +187,6 @@ private fun TvPlayerBottomSection(
subtitlesButtonEnabled: Boolean, subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
val playlistFocusRequester = remember { FocusRequester() } val playlistFocusRequester = remember { FocusRequester() }
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) } var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
@@ -210,24 +209,11 @@ private fun TvPlayerBottomSection(
.testTag(TvPlayerPlaylistStateTag) .testTag(TvPlayerPlaylistStateTag)
.semantics { stateDescription = playlistExpandState } .semantics { stateDescription = playlistExpandState }
) { ) {
Row( TvPlayerTimeRow(
modifier = Modifier positionMs = uiState.positionMs,
.fillMaxWidth() durationMs = uiState.durationMs,
.padding(horizontal = 4.dp), modifier = Modifier.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
)
}
TvPlayerSeekBar( TvPlayerSeekBar(
positionMs = uiState.positionMs, positionMs = uiState.positionMs,
durationMs = uiState.durationMs, 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( private fun handleExpandPlaylistKey(
event: androidx.compose.ui.input.key.KeyEvent, event: androidx.compose.ui.input.key.KeyEvent,
onExpand: () -> Unit onExpand: () -> Unit

View File

@@ -11,8 +11,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView 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.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility 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.PlayerGesturesLayer
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard 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.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.SkipSegmentButton
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
import kotlin.math.abs import kotlin.math.abs
@@ -73,6 +79,7 @@ fun PlayerScreen(
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) }
var showQueuePanel by remember { mutableStateOf(false) } var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) } var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
val overlayController = rememberPersistentOverlayController() val overlayController = rememberPersistentOverlayController()
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
@@ -136,8 +143,15 @@ fun PlayerScreen(
0 0
) )
}, },
onHorizontalDragPreview = { onHorizontalDragPreview = { deltaMs, previewPositionMs ->
horizontalSeekFeedback = it horizontalSeekFeedback = deltaMs
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
if (uiState.durationMs > 0L) {
it.coerceIn(0L, uiState.durationMs)
} else {
it.coerceAtLeast(0L)
}
}
}, },
onHorizontalDragSeekTo = { onHorizontalDragSeekTo = {
viewModel.seekTo(it) 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( ValueChangeTimedVisibility(
value = brightness, value = brightness,
hideAfterMillis = 800, hideAfterMillis = 800,
@@ -218,7 +251,10 @@ fun PlayerScreen(
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .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() }) 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 @Composable
private fun SeekAmountIndicator(deltaMs: Long, modifier: Modifier = Modifier) { private fun SeekAmountIndicator(deltaMs: Long, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme

View File

@@ -65,7 +65,6 @@ fun PlayerControlsOverlay(
onOpenQueue: () -> Unit, onOpenQueue: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
var scrubbing by remember { mutableStateOf(false) } var scrubbing by remember { mutableStateOf(false) }
Box( Box(
@@ -177,40 +176,14 @@ private fun BottomSection(
onQueueSelected: (String) -> Unit, onQueueSelected: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) { Column(modifier = modifier) {
Row( PlayerTimeRow(
modifier = Modifier positionMs = uiState.positionMs,
.fillMaxWidth() durationMs = uiState.durationMs,
.padding(horizontal = 8.dp), isLive = uiState.isLive,
horizontalArrangement = Arrangement.SpaceBetween, onSeekLiveEdge = onSeekLiveEdge,
verticalAlignment = Alignment.CenterVertically modifier = Modifier.padding(horizontal = 8.dp)
) { )
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
)
}
}
PlayerSeekBar( PlayerSeekBar(
positionMs = uiState.positionMs, positionMs = uiState.positionMs,
durationMs = uiState.durationMs, 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 { private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000 val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt() val seconds = (totalSeconds % 60).toInt()

View File

@@ -28,7 +28,7 @@ fun PlayerGesturesLayer(
onDoubleTapLeft: () -> Unit, onDoubleTapLeft: () -> Unit,
onVerticalDragLeft: (delta: Float) -> Unit, onVerticalDragLeft: (delta: Float) -> Unit,
onVerticalDragRight: (delta: Float) -> Unit, onVerticalDragRight: (delta: Float) -> Unit,
onHorizontalDragPreview: (deltaMs: Long?) -> Unit = {}, onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
onHorizontalDragSeekTo: (positionMs: Long) -> Unit, onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
currentPositionProvider: () -> Long, currentPositionProvider: () -> Long,
) { ) {
@@ -95,7 +95,10 @@ fun PlayerGesturesLayer(
val deltaMs = HorizontalSeekGestureHelper.deltaMs(accumulatedHorizontalDrag) val deltaMs = HorizontalSeekGestureHelper.deltaMs(accumulatedHorizontalDrag)
if (deltaMs != 0L && deltaMs != lastPreviewDelta) { if (deltaMs != 0L && deltaMs != lastPreviewDelta) {
lastPreviewDelta = deltaMs lastPreviewDelta = deltaMs
onHorizontalDragPreview(deltaMs) onHorizontalDragPreview(
deltaMs,
(startPositionMs + deltaMs).coerceAtLeast(0L)
)
} }
} }
} }
@@ -117,10 +120,10 @@ fun PlayerGesturesLayer(
if (deltaMs != 0L) { if (deltaMs != 0L) {
val targetMs = (startPositionMs + deltaMs).coerceAtLeast(0L) val targetMs = (startPositionMs + deltaMs).coerceAtLeast(0L)
onHorizontalDragSeekTo(targetMs) onHorizontalDragSeekTo(targetMs)
onHorizontalDragPreview(deltaMs) onHorizontalDragPreview(deltaMs, targetMs)
} }
} }
onHorizontalDragPreview(null) onHorizontalDragPreview(null, null)
} }
} }
) )