diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 71d0531a..f93a409d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -20,6 +20,8 @@ import kotlinx.serialization.json.put actual object PlayerSettingsStorage { private const val preferencesName = "nuvio_player_settings" private const val showLoadingOverlayKey = "show_loading_overlay" + private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" + private const val holdToSpeedValueKey = "hold_to_speed_value" private const val preferredAudioLanguageKey = "preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" @@ -51,6 +53,8 @@ actual object PlayerSettingsStorage { private const val libassRenderTypeKey = "libass_render_type" private val syncKeys = listOf( showLoadingOverlayKey, + holdToSpeedEnabledKey, + holdToSpeedValueKey, preferredAudioLanguageKey, secondaryPreferredAudioLanguageKey, preferredSubtitleLanguageKey, @@ -105,6 +109,40 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadHoldToSpeedEnabled(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(holdToSpeedEnabledKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveHoldToSpeedEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(holdToSpeedEnabledKey), enabled) + ?.apply() + } + + actual fun loadHoldToSpeedValue(): Float? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(holdToSpeedValueKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getFloat(key, 2f) + } else { + null + } + } + + actual fun saveHoldToSpeedValue(speed: Float) { + preferences + ?.edit() + ?.putFloat(ProfileScopedKey.of(holdToSpeedValueKey), speed) + ?.apply() + } + actual fun loadPreferredAudioLanguage(): String? = preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null) @@ -537,6 +575,8 @@ actual object PlayerSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } + loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } + loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } @@ -574,6 +614,8 @@ actual object PlayerSettingsStorage { }?.apply() payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) + payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) 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 0504f400..79c13013 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 @@ -412,7 +412,7 @@ private fun ProgressControls( onClick = onResizeModeClick, ) PlayerActionPillButton( - label = "${playbackSnapshot.playbackSpeed}x", + label = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), icon = Icons.Rounded.Speed, onClick = onSpeedClick, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt index bc1e46cb..88616160 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt @@ -142,3 +142,8 @@ internal fun formatPlaybackTime(positionMs: Long): String { "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" } } + +internal fun formatPlaybackSpeedLabel(speed: Float): String { + val normalized = speed.toString().trimEnd('0').trimEnd('.') + return "${normalized}x" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index e5728e63..22c18b7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -33,6 +33,8 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.rounded.Brightness6 import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.FastRewind import androidx.compose.material.icons.rounded.Speed import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -64,12 +66,16 @@ internal enum class GestureFeedbackIcon { Brightness, Volume, VolumeMuted, + SeekForward, + SeekBackward, } internal data class GestureFeedbackState( val message: String, val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed, val isDanger: Boolean = false, + val secondaryMessage: String? = null, + val secondaryMessageColor: Color? = null, ) @Composable @@ -208,7 +214,10 @@ internal fun GestureFeedbackPill( GestureFeedbackIcon.Brightness -> Icons.Rounded.Brightness6 GestureFeedbackIcon.Volume -> Icons.AutoMirrored.Rounded.VolumeUp GestureFeedbackIcon.VolumeMuted -> Icons.AutoMirrored.Rounded.VolumeOff + GestureFeedbackIcon.SeekForward -> Icons.Rounded.FastForward + GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind } + val iconTint = if (feedback.isDanger) Color(0xFFFFC1C1) else Color.White Row( modifier = modifier @@ -228,7 +237,7 @@ internal fun GestureFeedbackPill( Icon( imageVector = icon, contentDescription = null, - tint = Color.White, + tint = iconTint, modifier = Modifier.size(16.dp), ) } @@ -237,6 +246,13 @@ internal fun GestureFeedbackPill( style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold), color = Color.White, ) + feedback.secondaryMessage?.let { secondaryMessage -> + Text( + text = secondaryMessage, + style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), + color = feedback.secondaryMessageColor ?: Color.White.copy(alpha = 0.92f), + ) + } } } 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 c0ee1d32..7b2b5c9c 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 @@ -23,13 +23,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -57,9 +61,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.abs +import kotlin.math.roundToLong import kotlin.math.roundToInt private const val PlaybackProgressPersistIntervalMs = 60_000L +private const val PlayerDoubleTapSeekStepMs = 10_000L +private const val PlayerDoubleTapSeekResetDelayMs = 800L private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerRightGestureBoundary = 0.6f private const val PlayerVerticalGestureSensitivity = 1f @@ -79,6 +86,23 @@ private enum class PlayerSideGesture { Volume, } +private enum class PlayerSeekDirection { + Backward, + Forward, +} + +private enum class PlayerGestureMode { + HorizontalSeek, + Brightness, + Volume, +} + +private data class PlayerAccumulatedSeekState( + val direction: PlayerSeekDirection, + val baselinePositionMs: Long, + val amountMs: Long, +) + @Composable fun PlayerScreen( title: String, @@ -125,6 +149,7 @@ fun PlayerScreen( val sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding val overlayBottomPadding = sliderOverlayBottomPadding(metrics) val scope = rememberCoroutineScope() + val hapticFeedback = LocalHapticFeedback.current val gestureController = rememberPlayerGestureController() var controlsVisible by rememberSaveable { mutableStateOf(true) } // Active playback state (mutable to support source/episode switching) @@ -158,8 +183,13 @@ fun PlayerScreen( var scrubbingPositionMs by remember { mutableStateOf(null) } var pausedOverlayVisible by remember { mutableStateOf(false) } var gestureFeedback by remember { mutableStateOf(null) } + var liveGestureFeedback by remember { mutableStateOf(null) } + var renderedGestureFeedback by remember { mutableStateOf(null) } var gestureMessageJob by remember { mutableStateOf(null) } + var accumulatedSeekResetJob by remember { mutableStateOf(null) } + var accumulatedSeekState by remember { mutableStateOf(null) } var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) } + var speedBoostRestoreSpeed by remember(activeSourceUrl) { mutableStateOf(null) } var initialSeekApplied by remember(activeSourceUrl, activeInitialPositionMs, activeInitialProgressFraction) { val initialProgressFraction = activeInitialProgressFraction mutableStateOf( @@ -184,6 +214,13 @@ fun PlayerScreen( val backdropArtwork = background ?: poster val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback + + LaunchedEffect(currentGestureFeedback) { + if (currentGestureFeedback != null) { + renderedGestureFeedback = currentGestureFeedback + } + } // Sources & Episodes Panel state var showSourcesPanel by remember { mutableStateOf(false) } @@ -451,6 +488,48 @@ fun PlayerScreen( showGestureFeedback(GestureFeedbackState(message = message)) } + fun clearLiveGestureFeedback() { + liveGestureFeedback = null + } + + fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { + val seconds = amountMs / 1000L + if (seconds <= 0L) return + showGestureFeedback( + GestureFeedbackState( + message = if (direction == PlayerSeekDirection.Forward) "+${seconds}s" else "-${seconds}s", + icon = if (direction == PlayerSeekDirection.Forward) { + GestureFeedbackIcon.SeekForward + } else { + GestureFeedbackIcon.SeekBackward + }, + ), + ) + } + + fun showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) { + val deltaMs = previewPositionMs - baselinePositionMs + val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward + liveGestureFeedback = GestureFeedbackState( + message = formatPlaybackTime(previewPositionMs), + icon = if (direction == PlayerSeekDirection.Forward) { + GestureFeedbackIcon.SeekForward + } else { + GestureFeedbackIcon.SeekBackward + }, + secondaryMessage = buildString { + if (deltaMs >= 0L) append("+") + append((abs(deltaMs) / 1000f).roundToInt()) + append("s") + }, + secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { + Color(0xFF6EE7A8) + } else { + Color(0xFFFF9A76) + }, + ) + } + fun showBrightnessFeedback(level: Float) { val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() showGestureFeedback( @@ -489,9 +568,44 @@ fun PlayerScreen( fun seekBy(offsetMs: Long) { playerController?.seekBy(offsetMs) controlsVisible = true - val seconds = offsetMs / 1000L - if (seconds != 0L) { - showGestureMessage(if (seconds > 0) "+${seconds}s" else "${seconds}s") + when { + offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) + offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) + } + } + + fun handleDoubleTapSeek(direction: PlayerSeekDirection) { + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + val nextState = if (accumulatedSeekState?.direction == direction) { + accumulatedSeekState!!.copy(amountMs = accumulatedSeekState!!.amountMs + PlayerDoubleTapSeekStepMs) + } else { + PlayerAccumulatedSeekState( + direction = direction, + baselinePositionMs = currentPositionMs, + amountMs = PlayerDoubleTapSeekStepMs, + ) + } + accumulatedSeekState = nextState + + val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L } + val targetPositionMs = when (direction) { + PlayerSeekDirection.Backward -> { + (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L) + } + + PlayerSeekDirection.Forward -> { + val unclamped = nextState.baselinePositionMs + nextState.amountMs + maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped + } + } + playerController?.seekTo(targetPositionMs) + controlsVisible = true + showSeekFeedback(direction, nextState.amountMs) + + accumulatedSeekResetJob?.cancel() + accumulatedSeekResetJob = scope.launch { + delay(PlayerDoubleTapSeekResetDelayMs) + accumulatedSeekState = null } } @@ -506,10 +620,69 @@ fun PlayerScreen( val current = playbackSnapshot.playbackSpeed val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() playerController?.setPlaybackSpeed(next) - showGestureMessage("${next}x") + showGestureMessage(formatPlaybackSpeedLabel(next)) controlsVisible = true } + fun activateHoldToSpeed() { + if (!playerSettingsUiState.holdToSpeedEnabled) return + val controller = playerController ?: return + if (speedBoostRestoreSpeed != null) return + + val targetSpeed = playerSettingsUiState.holdToSpeedValue + val currentSpeed = playbackSnapshot.playbackSpeed + if (abs(currentSpeed - targetSpeed) < 0.01f) return + + speedBoostRestoreSpeed = currentSpeed + controller.setPlaybackSpeed(targetSpeed) + liveGestureFeedback = GestureFeedbackState( + message = formatPlaybackSpeedLabel(targetSpeed), + icon = GestureFeedbackIcon.Speed, + ) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + + fun deactivateHoldToSpeed() { + val restoreSpeed = speedBoostRestoreSpeed ?: return + playerController?.setPlaybackSpeed(restoreSpeed) + speedBoostRestoreSpeed = null + liveGestureFeedback = null + } + + val onSurfaceTap = rememberUpdatedState { offset: Offset -> + val centerStart = layoutSize.width * PlayerLeftGestureBoundary + val centerEnd = layoutSize.width * PlayerRightGestureBoundary + if (controlsVisible && offset.x in centerStart..centerEnd) { + controlsVisible = false + } else { + controlsVisible = !controlsVisible + } + } + val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> + when { + offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { + handleDoubleTapSeek(PlayerSeekDirection.Backward) + } + + offset.x > layoutSize.width * PlayerRightGestureBoundary -> { + handleDoubleTapSeek(PlayerSeekDirection.Forward) + } + + else -> controlsVisible = !controlsVisible + } + } + val activateHoldToSpeedState = rememberUpdatedState(::activateHoldToSpeed) + val deactivateHoldToSpeedState = rememberUpdatedState(::deactivateHoldToSpeed) + val showHorizontalSeekPreviewState = rememberUpdatedState(::showHorizontalSeekPreview) + val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback) + val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback) + val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback) + val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)) + val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs) + val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long -> + playerController?.seekTo(targetPositionMs) + } + fun switchToSource(stream: StreamItem) { val url = stream.directPlaybackUrl ?: return if (url == activeSourceUrl) return @@ -758,9 +931,15 @@ fun PlayerScreen( playerControllerSourceUrl = null playbackSnapshot = PlayerPlaybackSnapshot() scrubbingPositionMs = null + liveGestureFeedback = null + renderedGestureFeedback = null initialLoadCompleted = false lastProgressPersistEpochMs = 0L previousIsPlaying = false + accumulatedSeekResetJob?.cancel() + accumulatedSeekResetJob = null + accumulatedSeekState = null + speedBoostRestoreSpeed = null preferredAudioSelectionApplied = false preferredSubtitleSelectionApplied = false showSourcesPanel = false @@ -1021,52 +1200,45 @@ fun PlayerScreen( modifier = Modifier .fillMaxSize() .onSizeChanged { layoutSize = it } - .pointerInput(controlsVisible, playerController) { + .pointerInput(layoutSize) { detectTapGestures( - onTap = { offset -> - val centerStart = layoutSize.width * PlayerLeftGestureBoundary - val centerEnd = layoutSize.width * PlayerRightGestureBoundary - if (controlsVisible && offset.x in centerStart..centerEnd) { - controlsVisible = false - } else { - controlsVisible = !controlsVisible - } - }, - onDoubleTap = { offset -> - when { - offset.x < layoutSize.width * PlayerLeftGestureBoundary -> seekBy(-10_000L) - offset.x > layoutSize.width * PlayerRightGestureBoundary -> seekBy(10_000L) - else -> controlsVisible = !controlsVisible - } + onPress = { + tryAwaitRelease() + deactivateHoldToSpeedState.value() }, + onTap = { offset -> onSurfaceTap.value(offset) }, + onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, + onLongPress = { activateHoldToSpeedState.value() }, ) } .pointerInput(gestureController, layoutSize) { - val controller = gestureController ?: return@pointerInput awaitEachGesture { val down = awaitFirstDown() - val width = size.width.toFloat() + val controller = gestureController + val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture val region = when { down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume else -> null - } ?: return@awaitEachGesture + } val initialBrightness = if (region == PlayerSideGesture.Brightness) { - controller.currentBrightness() ?: return@awaitEachGesture + controller?.currentBrightness() } else { - 0f + null } val initialVolume = if (region == PlayerSideGesture.Volume) { - controller.currentVolume() ?: return@awaitEachGesture + controller?.currentVolume() } else { null } var totalDx = 0f var totalDy = 0f - var dragging = false + var gestureMode: PlayerGestureMode? = null + val horizontalSeekBaselineMs = currentPositionMsState.value + var horizontalSeekPreviewMs = horizontalSeekBaselineMs while (true) { val event = awaitPointerEvent() @@ -1077,37 +1249,79 @@ fun PlayerScreen( totalDx += delta.x totalDy += delta.y - if (!dragging) { - val pastVerticalSlop = abs(totalDy) > viewConfiguration.touchSlop - val verticalDominant = abs(totalDy) > abs(totalDx) + if (gestureMode == null) { val horizontalDominant = abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy) + val verticalDominant = + abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) - if (horizontalDominant) { - break + gestureMode = when { + horizontalDominant -> { + deactivateHoldToSpeedState.value() + PlayerGestureMode.HorizontalSeek + } + + verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> { + PlayerGestureMode.Brightness + } + + verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> { + PlayerGestureMode.Volume + } + + else -> null } - if (!pastVerticalSlop || !verticalDominant) { + + if (gestureMode == null) { continue } - dragging = true } - val gestureDeltaFraction = - (-totalDy / height) * PlayerVerticalGestureSensitivity - - when (region) { - PlayerSideGesture.Brightness -> { - controller.setBrightness(initialBrightness + gestureDeltaFraction) - ?.let(::showBrightnessFeedback) + when (gestureMode) { + PlayerGestureMode.HorizontalSeek -> { + val sensitivitySeconds = when { + currentDurationMsState.value >= 3_600_000L -> 120f + currentDurationMsState.value >= 1_800_000L -> 90f + else -> 60f + } + val previewOffsetMs = + ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong() + val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs + horizontalSeekPreviewMs = currentDurationMsState.value + .takeIf { it > 0L } + ?.let { durationMs -> + unclampedPreviewMs.coerceIn(0L, durationMs) + } + ?: unclampedPreviewMs.coerceAtLeast(0L) + showHorizontalSeekPreviewState.value( + horizontalSeekPreviewMs, + horizontalSeekBaselineMs, + ) } - PlayerSideGesture.Volume -> { - controller.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) - ?.let(::showVolumeFeedback) + PlayerGestureMode.Brightness -> { + val gestureDeltaFraction = + (-totalDy / height) * PlayerVerticalGestureSensitivity + controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction) + ?.let(showBrightnessFeedbackState.value) } + + PlayerGestureMode.Volume -> { + val gestureDeltaFraction = + (-totalDy / height) * PlayerVerticalGestureSensitivity + controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) + ?.let(showVolumeFeedbackState.value) + } + + null -> Unit } change.consume() } + + if (gestureMode == PlayerGestureMode.HorizontalSeek) { + commitHorizontalSeekState.value(horizontalSeekPreviewMs) + clearLiveGestureFeedbackState.value() + } } }, ) { @@ -1215,21 +1429,23 @@ fun PlayerScreen( } AnimatedVisibility( - visible = gestureFeedback != null, + visible = currentGestureFeedback != null, enter = fadeIn(), exit = fadeOut(), ) { Box( modifier = Modifier.fillMaxSize(), ) { - GestureFeedbackPill( - feedback = gestureFeedback ?: GestureFeedbackState(message = ""), - modifier = Modifier - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top)) - .padding(horizontal = horizontalSafePadding) - .padding(top = 40.dp), - ) + renderedGestureFeedback?.let { feedback -> + GestureFeedbackPill( + feedback = feedback, + modifier = Modifier + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top)) + .padding(horizontal = horizontalSafePadding) + .padding(top = 40.dp), + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 3d1f2054..6d07096d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow data class PlayerSettingsUiState( val showLoadingOverlay: Boolean = true, + val holdToSpeedEnabled: Boolean = true, + val holdToSpeedValue: Float = 2f, val preferredAudioLanguage: String = AudioLanguageOption.DEVICE, val secondaryPreferredAudioLanguage: String? = null, val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE, @@ -44,6 +46,8 @@ object PlayerSettingsRepository { private var hasLoaded = false private var showLoadingOverlay = true + private var holdToSpeedEnabled = true + private var holdToSpeedValue = 2f private var preferredAudioLanguage = AudioLanguageOption.DEVICE private var secondaryPreferredAudioLanguage: String? = null private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE @@ -83,6 +87,8 @@ object PlayerSettingsRepository { fun clearLocalState() { hasLoaded = false showLoadingOverlay = true + holdToSpeedEnabled = true + holdToSpeedValue = 2f preferredAudioLanguage = AudioLanguageOption.DEVICE secondaryPreferredAudioLanguage = null preferredSubtitleLanguage = SubtitleLanguageOption.NONE @@ -115,6 +121,8 @@ object PlayerSettingsRepository { private fun loadFromDisk() { hasLoaded = true showLoadingOverlay = PlayerSettingsStorage.loadShowLoadingOverlay() ?: true + holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true + holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f preferredAudioLanguage = normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage()) ?: AudioLanguageOption.DEVICE @@ -184,6 +192,23 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveShowLoadingOverlay(enabled) } + fun setHoldToSpeedEnabled(enabled: Boolean) { + ensureLoaded() + if (holdToSpeedEnabled == enabled) return + holdToSpeedEnabled = enabled + publish() + PlayerSettingsStorage.saveHoldToSpeedEnabled(enabled) + } + + fun setHoldToSpeedValue(speed: Float) { + ensureLoaded() + val normalized = speed.coerceIn(1f, 4f) + if (holdToSpeedValue == normalized) return + holdToSpeedValue = normalized + publish() + PlayerSettingsStorage.saveHoldToSpeedValue(normalized) + } + fun setPreferredAudioLanguage(language: String) { ensureLoaded() val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE @@ -404,6 +429,8 @@ object PlayerSettingsRepository { private fun publish() { _uiState.value = PlayerSettingsUiState( showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 27909b2a..ba19ee86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -5,6 +5,10 @@ import kotlinx.serialization.json.JsonObject internal expect object PlayerSettingsStorage { fun loadShowLoadingOverlay(): Boolean? fun saveShowLoadingOverlay(enabled: Boolean) + fun loadHoldToSpeedEnabled(): Boolean? + fun saveHoldToSpeedEnabled(enabled: Boolean) + fun loadHoldToSpeedValue(): Float? + fun saveHoldToSpeedValue(speed: Float) fun loadPreferredAudioLanguage(): String? fun savePreferredAudioLanguage(language: String) fun loadSecondaryPreferredAudioLanguage(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 8663d9a9..590338df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -51,6 +51,7 @@ import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.SubtitleLanguageOption +import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.languageLabelForCode import com.nuvio.app.features.plugins.PluginsUiState import com.nuvio.app.features.plugins.PluginRepository @@ -61,6 +62,8 @@ import com.nuvio.app.isIos internal fun LazyListScope.playbackSettingsContent( isTablet: Boolean, showLoadingOverlay: Boolean, + holdToSpeedEnabled: Boolean, + holdToSpeedValue: Float, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -77,6 +80,8 @@ internal fun LazyListScope.playbackSettingsContent( PlaybackSettingsSection( isTablet = isTablet, showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -96,6 +101,8 @@ internal fun LazyListScope.playbackSettingsContent( private fun PlaybackSettingsSection( isTablet: Boolean, showLoadingOverlay: Boolean, + holdToSpeedEnabled: Boolean, + holdToSpeedValue: Float, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -114,6 +121,7 @@ private fun PlaybackSettingsSection( var showSecondarySubtitleDialog by remember { mutableStateOf(false) } var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } + var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } var showLibassRenderTypeDialog by remember { mutableStateOf(false) } var showAutoPlayModeDialog by remember { mutableStateOf(false) } var showAutoPlaySourceDialog by remember { mutableStateOf(false) } @@ -147,6 +155,23 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = "Hold To Speed", + description = "Long-press anywhere on the player surface to temporarily boost playback speed.", + checked = holdToSpeedEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setHoldToSpeedEnabled, + ) + if (holdToSpeedEnabled) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Hold Speed", + description = formatPlaybackSpeedLabel(holdToSpeedValue), + isTablet = isTablet, + onClick = { showHoldToSpeedValueDialog = true }, + ) + } } } @@ -713,6 +738,17 @@ private fun PlaybackSettingsSection( ) } + if (showHoldToSpeedValueDialog) { + HoldToSpeedValueDialog( + selectedSpeed = holdToSpeedValue, + onSpeedSelected = { speed -> + PlayerSettingsRepository.setHoldToSpeedValue(speed) + showHoldToSpeedValueDialog = false + }, + onDismiss = { showHoldToSpeedValueDialog = false }, + ) + } + if (showLibassRenderTypeDialog) { LibassRenderTypeDialog( selectedRenderType = libassRenderType, @@ -1080,6 +1116,93 @@ private fun DecoderPriorityDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun HoldToSpeedValueDialog( + selectedSpeed: Float, + onSpeedSelected: (Float) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf(1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f) + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Hold Speed", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { speed -> + val isSelected = speed == selectedSpeed + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSpeedSelected(speed) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatPlaybackSpeedLabel(speed), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Tap outside to close", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun LibassRenderTypeDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index be3c734c..8762ed87 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -152,6 +152,8 @@ fun SettingsScreen( page = page, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, + holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, @@ -184,6 +186,8 @@ fun SettingsScreen( page = page, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, + holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, @@ -226,6 +230,8 @@ private fun MobileSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, + holdToSpeedEnabled: Boolean, + holdToSpeedValue: Float, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -286,6 +292,8 @@ private fun MobileSettingsScreen( SettingsPage.Playback -> playbackSettingsContent( isTablet = false, showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -365,6 +373,8 @@ private fun TabletSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, + holdToSpeedEnabled: Boolean, + holdToSpeedValue: Float, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -488,6 +498,8 @@ private fun TabletSettingsScreen( SettingsPage.Playback -> playbackSettingsContent( isTablet = true, showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index cfa20e98..bf78feb7 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -18,6 +18,8 @@ import platform.Foundation.NSUserDefaults actual object PlayerSettingsStorage { private const val showLoadingOverlayKey = "show_loading_overlay" + private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" + private const val holdToSpeedValueKey = "hold_to_speed_value" private const val preferredAudioLanguageKey = "preferred_audio_language" private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" @@ -49,6 +51,8 @@ actual object PlayerSettingsStorage { private const val libassRenderTypeKey = "libass_render_type" private val syncKeys = listOf( showLoadingOverlayKey, + holdToSpeedEnabledKey, + holdToSpeedValueKey, preferredAudioLanguageKey, secondaryPreferredAudioLanguageKey, preferredSubtitleLanguageKey, @@ -94,6 +98,34 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(showLoadingOverlayKey)) } + actual fun loadHoldToSpeedEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(holdToSpeedEnabledKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveHoldToSpeedEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(holdToSpeedEnabledKey)) + } + + actual fun loadHoldToSpeedValue(): Float? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(holdToSpeedValueKey) + return if (defaults.objectForKey(key) != null) { + defaults.floatForKey(key) + } else { + null + } + } + + actual fun saveHoldToSpeedValue(speed: Float) { + NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey)) + } + actual fun loadPreferredAudioLanguage(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(preferredAudioLanguageKey) @@ -450,6 +482,8 @@ actual object PlayerSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } + loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } + loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } @@ -487,6 +521,8 @@ actual object PlayerSettingsStorage { } payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) + payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)