feat: player lock

This commit is contained in:
tapframe 2026-04-20 17:33:34 +05:30
parent 2f032bae81
commit 4a04e12e42
2 changed files with 265 additions and 28 deletions

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -21,6 +22,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Forward10 import androidx.compose.material.icons.rounded.Forward10
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Replay10
import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.Speed
import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.SwapHoriz
@ -29,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -61,6 +65,8 @@ internal fun PlayerControlsShell(
displayedPositionMs: Long, displayedPositionMs: Long,
metrics: PlayerLayoutMetrics, metrics: PlayerLayoutMetrics,
resizeMode: PlayerResizeMode, resizeMode: PlayerResizeMode,
isLocked: Boolean,
onLockToggle: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onTogglePlayback: () -> Unit, onTogglePlayback: () -> Unit,
onSeekBack: () -> Unit, onSeekBack: () -> Unit,
@ -120,6 +126,8 @@ internal fun PlayerControlsShell(
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
metrics = metrics, metrics = metrics,
isLocked = isLocked,
onLockToggle = onLockToggle,
onBack = onBack, onBack = onBack,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
@ -175,6 +183,8 @@ private fun PlayerHeader(
episodeNumber: Int?, episodeNumber: Int?,
episodeTitle: String?, episodeTitle: String?,
metrics: PlayerLayoutMetrics, metrics: PlayerLayoutMetrics,
isLocked: Boolean,
onLockToggle: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -240,6 +250,17 @@ private fun PlayerHeader(
} }
} }
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerHeaderIconButton(
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
contentDescription = if (isLocked) "Unlock player controls" else "Lock player controls",
buttonSize = metrics.headerIconSize + 16.dp,
iconSize = metrics.headerIconSize,
onClick = onLockToggle,
)
NuvioBackButton( NuvioBackButton(
onClick = onBack, onClick = onBack,
containerColor = Color.Black.copy(alpha = 0.35f), containerColor = Color.Black.copy(alpha = 0.35f),
@ -250,6 +271,32 @@ private fun PlayerHeader(
) )
} }
} }
}
}
@Composable
private fun PlayerHeaderIconButton(
icon: ImageVector,
contentDescription: String,
buttonSize: androidx.compose.ui.unit.Dp,
iconSize: androidx.compose.ui.unit.Dp,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.35f))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(iconSize),
)
}
} }
@Composable @Composable
@ -446,6 +493,105 @@ private fun ProgressControls(
} }
} }
@Composable
internal fun LockedPlayerOverlay(
playbackSnapshot: PlayerPlaybackSnapshot,
displayedPositionMs: Long,
metrics: PlayerLayoutMetrics,
horizontalSafePadding: androidx.compose.ui.unit.Dp,
onUnlock: () -> Unit,
modifier: Modifier = Modifier,
) {
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
val sliderColors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.28f),
disabledThumbColor = Color.White,
disabledActiveTrackColor = Color.White,
disabledInactiveTrackColor = Color.White.copy(alpha = 0.28f),
)
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.72f),
),
),
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(78.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.52f))
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape)
.clickable(onClick = onUnlock),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Rounded.Lock,
contentDescription = "Unlock player controls",
tint = Color.White,
modifier = Modifier.size(34.dp),
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Tap to unlock",
style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
color = Color.White.copy(alpha = 0.92f),
)
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = horizontalSafePadding + metrics.horizontalPadding)
.padding(bottom = metrics.sliderBottomOffset),
) {
Slider(
modifier = Modifier
.fillMaxWidth()
.height(metrics.sliderTouchHeight)
.graphicsLayer(scaleY = metrics.sliderScaleY),
value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
onValueChange = {},
onValueChangeFinished = {},
valueRange = 0f..durationMs.toFloat(),
enabled = false,
colors = sliderColors,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.padding(top = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize)
TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize)
}
}
}
}
@Composable @Composable
private fun TimePill( private fun TimePill(
text: String, text: String,

View file

@ -69,6 +69,7 @@ import kotlin.math.roundToInt
private const val PlaybackProgressPersistIntervalMs = 60_000L private const val PlaybackProgressPersistIntervalMs = 60_000L
private const val PlayerDoubleTapSeekStepMs = 10_000L private const val PlayerDoubleTapSeekStepMs = 10_000L
private const val PlayerDoubleTapSeekResetDelayMs = 800L private const val PlayerDoubleTapSeekResetDelayMs = 800L
private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f private const val PlayerVerticalGestureSensitivity = 1f
@ -154,6 +155,7 @@ fun PlayerScreen(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val gestureController = rememberPlayerGestureController() val gestureController = rememberPlayerGestureController()
var controlsVisible by rememberSaveable { mutableStateOf(true) } var controlsVisible by rememberSaveable { mutableStateOf(true) }
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
// Active playback state (mutable to support source/episode switching) // Active playback state (mutable to support source/episode switching)
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) } var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) } var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
@ -189,6 +191,7 @@ fun PlayerScreen(
var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) } var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) } var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) } var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var lockedOverlayVisible by remember { mutableStateOf(false) }
var gestureMessageJob by remember { mutableStateOf<Job?>(null) } var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) } var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) } var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
@ -497,6 +500,35 @@ fun PlayerScreen(
liveGestureFeedback = null liveGestureFeedback = null
} }
fun revealLockedOverlay() {
controlsVisible = false
lockedOverlayVisible = true
}
fun lockPlayerControls() {
playerControlsLocked = true
controlsVisible = false
lockedOverlayVisible = false
pausedOverlayVisible = false
scrubbingPositionMs = null
gestureMessageJob?.cancel()
gestureFeedback = null
liveGestureFeedback = null
renderedGestureFeedback = null
showAudioModal = false
showSubtitleModal = false
showSourcesPanel = false
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
PlayerStreamsRepository.clearEpisodeStreams()
}
fun unlockPlayerControls() {
playerControlsLocked = false
lockedOverlayVisible = false
controlsVisible = true
}
fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
val seconds = amountMs / 1000L val seconds = amountMs / 1000L
if (seconds <= 0L) return if (seconds <= 0L) return
@ -659,6 +691,10 @@ fun PlayerScreen(
} }
val onSurfaceTap = rememberUpdatedState { offset: Offset -> val onSurfaceTap = rememberUpdatedState { offset: Offset ->
if (playerControlsLocked) {
revealLockedOverlay()
return@rememberUpdatedState
}
val centerStart = layoutSize.width * PlayerLeftGestureBoundary val centerStart = layoutSize.width * PlayerLeftGestureBoundary
val centerEnd = layoutSize.width * PlayerRightGestureBoundary val centerEnd = layoutSize.width * PlayerRightGestureBoundary
if (controlsVisible && offset.x in centerStart..centerEnd) { if (controlsVisible && offset.x in centerStart..centerEnd) {
@ -668,6 +704,10 @@ fun PlayerScreen(
} }
} }
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
if (playerControlsLocked) {
revealLockedOverlay()
return@rememberUpdatedState
}
when { when {
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
handleDoubleTapSeek(PlayerSeekDirection.Backward) handleDoubleTapSeek(PlayerSeekDirection.Backward)
@ -686,7 +726,9 @@ fun PlayerScreen(
val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback) val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback) val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback) val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay)
val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive) val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
val playerControlsLockedState = rememberUpdatedState(playerControlsLocked)
val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)) val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs) val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long -> val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
@ -1002,6 +1044,7 @@ fun PlayerScreen(
scrubbingPositionMs = null scrubbingPositionMs = null
liveGestureFeedback = null liveGestureFeedback = null
renderedGestureFeedback = null renderedGestureFeedback = null
lockedOverlayVisible = false
initialLoadCompleted = false initialLoadCompleted = false
lastProgressPersistEpochMs = 0L lastProgressPersistEpochMs = 0L
previousIsPlaying = false previousIsPlaying = false
@ -1096,6 +1139,14 @@ fun PlayerScreen(
controlsVisible = false controlsVisible = false
} }
LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
if (!playerControlsLocked || !lockedOverlayVisible) {
return@LaunchedEffect
}
delay(PlayerLockedOverlayDurationMs)
lockedOverlayVisible = false
}
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
pausedOverlayVisible = false pausedOverlayVisible = false
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
@ -1277,12 +1328,27 @@ fun PlayerScreen(
}, },
onTap = { offset -> onSurfaceTap.value(offset) }, onTap = { offset -> onSurfaceTap.value(offset) },
onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
onLongPress = { activateHoldToSpeedState.value() }, onLongPress = {
if (playerControlsLockedState.value) {
revealLockedOverlayState.value()
} else {
activateHoldToSpeedState.value()
}
},
) )
} }
.pointerInput(gestureController, layoutSize) { .pointerInput(gestureController, layoutSize) {
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown() val down = awaitFirstDown()
if (playerControlsLockedState.value) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
change.consume()
}
return@awaitEachGesture
}
val controller = gestureController val controller = gestureController
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
@ -1415,18 +1481,18 @@ fun PlayerScreen(
} }
if (snapshot.isEnded) { if (snapshot.isEnded) {
shouldPlay = false shouldPlay = false
controlsVisible = true controlsVisible = !playerControlsLocked
} }
}, },
onError = { message -> onError = { message ->
errorMessage = message errorMessage = message
if (message != null) { if (message != null) {
controlsVisible = true controlsVisible = !playerControlsLocked
} }
}, },
) )
if (pausedOverlayVisible && !controlsVisible) { if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) {
PauseMetadataOverlay( PauseMetadataOverlay(
title = title, title = title,
logo = logo, logo = logo,
@ -1443,7 +1509,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible, visible = controlsVisible && !playerControlsLocked,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
@ -1458,6 +1524,14 @@ fun PlayerScreen(
displayedPositionMs = displayedPositionMs, displayedPositionMs = displayedPositionMs,
metrics = metrics, metrics = metrics,
resizeMode = resizeMode, resizeMode = resizeMode,
isLocked = playerControlsLocked,
onLockToggle = {
if (playerControlsLocked) {
unlockPlayerControls()
} else {
lockPlayerControls()
}
},
onBack = onBackWithProgress, onBack = onBackWithProgress,
onTogglePlayback = ::togglePlayback, onTogglePlayback = ::togglePlayback,
onSeekBack = { seekBy(-10_000L) }, onSeekBack = { seekBy(-10_000L) },
@ -1484,6 +1558,21 @@ fun PlayerScreen(
) )
} }
AnimatedVisibility(
visible = playerControlsLocked && lockedOverlayVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
LockedPlayerOverlay(
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
horizontalSafePadding = horizontalSafePadding,
onUnlock = ::unlockPlayerControls,
modifier = Modifier.fillMaxSize(),
)
}
AnimatedVisibility( AnimatedVisibility(
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
enter = fadeIn(), enter = fadeIn(),
@ -1521,6 +1610,7 @@ fun PlayerScreen(
} }
// Skip intro/recap/outro button // Skip intro/recap/outro button
if (!playerControlsLocked) {
SkipIntroButton( SkipIntroButton(
interval = activeSkipInterval, interval = activeSkipInterval,
dismissed = skipIntervalDismissed, dismissed = skipIntervalDismissed,
@ -1535,9 +1625,10 @@ fun PlayerScreen(
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding), .padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
) )
}
// Next episode card // Next episode card
if (isSeries) { if (isSeries && !playerControlsLocked) {
NextEpisodeCard( NextEpisodeCard(
nextEpisode = nextEpisodeInfo, nextEpisode = nextEpisodeInfo,
visible = showNextEpisodeCard, visible = showNextEpisodeCard,