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

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