From 4a04e12e42afc11ae115deb2d9e938035c6fa1cf Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:33:34 +0530 Subject: [PATCH] feat: player lock --- .../app/features/player/PlayerControls.kt | 162 +++++++++++++++++- .../nuvio/app/features/player/PlayerScreen.kt | 131 +++++++++++--- 2 files changed, 265 insertions(+), 28 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 79c13013..d19c5334 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides 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.material.icons.Icons 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.Speed 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.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -61,6 +65,8 @@ internal fun PlayerControlsShell( displayedPositionMs: Long, metrics: PlayerLayoutMetrics, resizeMode: PlayerResizeMode, + isLocked: Boolean, + onLockToggle: () -> Unit, onBack: () -> Unit, onTogglePlayback: () -> Unit, onSeekBack: () -> Unit, @@ -120,6 +126,8 @@ internal fun PlayerControlsShell( episodeNumber = episodeNumber, episodeTitle = episodeTitle, metrics = metrics, + isLocked = isLocked, + onLockToggle = onLockToggle, onBack = onBack, modifier = Modifier .align(Alignment.TopStart) @@ -175,6 +183,8 @@ private fun PlayerHeader( episodeNumber: Int?, episodeTitle: String?, metrics: PlayerLayoutMetrics, + isLocked: Boolean, + onLockToggle: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -240,18 +250,55 @@ private fun PlayerHeader( } } - NuvioBackButton( - onClick = onBack, - containerColor = Color.Black.copy(alpha = 0.35f), - contentColor = Color.White, - buttonSize = metrics.headerIconSize + 16.dp, - iconSize = metrics.headerIconSize, - contentDescription = "Close player", - ) + 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( + onClick = onBack, + containerColor = Color.Black.copy(alpha = 0.35f), + contentColor = Color.White, + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + contentDescription = "Close player", + ) + } } } } +@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 private fun CenterControls( snapshot: PlayerPlaybackSnapshot, @@ -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 private fun TimePill( text: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 78578511..81c52ea4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -69,6 +69,7 @@ import kotlin.math.roundToInt private const val PlaybackProgressPersistIntervalMs = 60_000L private const val PlayerDoubleTapSeekStepMs = 10_000L private const val PlayerDoubleTapSeekResetDelayMs = 800L +private const val PlayerLockedOverlayDurationMs = 2_000L private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerRightGestureBoundary = 0.6f private const val PlayerVerticalGestureSensitivity = 1f @@ -154,6 +155,7 @@ fun PlayerScreen( val hapticFeedback = LocalHapticFeedback.current val gestureController = rememberPlayerGestureController() var controlsVisible by rememberSaveable { mutableStateOf(true) } + var playerControlsLocked by rememberSaveable { mutableStateOf(false) } // Active playback state (mutable to support source/episode switching) var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) } var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) } @@ -189,6 +191,7 @@ fun PlayerScreen( var gestureFeedback by remember { mutableStateOf(null) } var liveGestureFeedback by remember { mutableStateOf(null) } var renderedGestureFeedback by remember { mutableStateOf(null) } + var lockedOverlayVisible by remember { mutableStateOf(false) } var gestureMessageJob by remember { mutableStateOf(null) } var accumulatedSeekResetJob by remember { mutableStateOf(null) } var accumulatedSeekState by remember { mutableStateOf(null) } @@ -497,6 +500,35 @@ fun PlayerScreen( 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) { val seconds = amountMs / 1000L if (seconds <= 0L) return @@ -659,6 +691,10 @@ fun PlayerScreen( } val onSurfaceTap = rememberUpdatedState { offset: Offset -> + if (playerControlsLocked) { + revealLockedOverlay() + return@rememberUpdatedState + } val centerStart = layoutSize.width * PlayerLeftGestureBoundary val centerEnd = layoutSize.width * PlayerRightGestureBoundary if (controlsVisible && offset.x in centerStart..centerEnd) { @@ -668,6 +704,10 @@ fun PlayerScreen( } } val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> + if (playerControlsLocked) { + revealLockedOverlay() + return@rememberUpdatedState + } when { offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { handleDoubleTapSeek(PlayerSeekDirection.Backward) @@ -686,7 +726,9 @@ fun PlayerScreen( val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback) val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback) val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback) + val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay) val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive) + val playerControlsLockedState = rememberUpdatedState(playerControlsLocked) val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)) val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs) val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long -> @@ -1002,6 +1044,7 @@ fun PlayerScreen( scrubbingPositionMs = null liveGestureFeedback = null renderedGestureFeedback = null + lockedOverlayVisible = false initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false @@ -1096,6 +1139,14 @@ fun PlayerScreen( controlsVisible = false } + LaunchedEffect(playerControlsLocked, lockedOverlayVisible) { + if (!playerControlsLocked || !lockedOverlayVisible) { + return@LaunchedEffect + } + delay(PlayerLockedOverlayDurationMs) + lockedOverlayVisible = false + } + LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { pausedOverlayVisible = false if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { @@ -1277,12 +1328,27 @@ fun PlayerScreen( }, onTap = { offset -> onSurfaceTap.value(offset) }, onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, - onLongPress = { activateHoldToSpeedState.value() }, + onLongPress = { + if (playerControlsLockedState.value) { + revealLockedOverlayState.value() + } else { + activateHoldToSpeedState.value() + } + }, ) } .pointerInput(gestureController, layoutSize) { awaitEachGesture { 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 width = size.width.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) { shouldPlay = false - controlsVisible = true + controlsVisible = !playerControlsLocked } }, onError = { message -> errorMessage = message if (message != null) { - controlsVisible = true + controlsVisible = !playerControlsLocked } }, ) - if (pausedOverlayVisible && !controlsVisible) { + if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) { PauseMetadataOverlay( title = title, logo = logo, @@ -1443,7 +1509,7 @@ fun PlayerScreen( } AnimatedVisibility( - visible = controlsVisible, + visible = controlsVisible && !playerControlsLocked, enter = fadeIn(), exit = fadeOut(), ) { @@ -1458,6 +1524,14 @@ fun PlayerScreen( displayedPositionMs = displayedPositionMs, metrics = metrics, resizeMode = resizeMode, + isLocked = playerControlsLocked, + onLockToggle = { + if (playerControlsLocked) { + unlockPlayerControls() + } else { + lockPlayerControls() + } + }, onBack = onBackWithProgress, onTogglePlayback = ::togglePlayback, 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( visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, enter = fadeIn(), @@ -1521,23 +1610,25 @@ fun PlayerScreen( } // Skip intro/recap/outro button - SkipIntroButton( - interval = activeSkipInterval, - dismissed = skipIntervalDismissed, - controlsVisible = controlsVisible, - onSkip = { - val interval = activeSkipInterval ?: return@SkipIntroButton - playerController?.seekTo((interval.endTime * 1000).toLong()) - skipIntervalDismissed = true - }, - onDismiss = { skipIntervalDismissed = true }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = sliderEdgePadding, bottom = overlayBottomPadding), - ) + if (!playerControlsLocked) { + SkipIntroButton( + interval = activeSkipInterval, + dismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkip = { + val interval = activeSkipInterval ?: return@SkipIntroButton + playerController?.seekTo((interval.endTime * 1000).toLong()) + skipIntervalDismissed = true + }, + onDismiss = { skipIntervalDismissed = true }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = sliderEdgePadding, bottom = overlayBottomPadding), + ) + } // Next episode card - if (isSeries) { + if (isSeries && !playerControlsLocked) { NextEpisodeCard( nextEpisode = nextEpisodeInfo, visible = showNextEpisodeCard,