mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat: enhance player UI with hidden seek timeline and time display improvements
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user