feat: adding more player gestures - horizontal scrubbing, hold to speed up.

This commit is contained in:
tapframe 2026-04-10 21:10:22 +05:30
parent f93091aa1b
commit 2b400cf6fd
10 changed files with 536 additions and 55 deletions

View file

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

View file

@ -412,7 +412,7 @@ private fun ProgressControls(
onClick = onResizeModeClick,
)
PlayerActionPillButton(
label = "${playbackSnapshot.playbackSpeed}x",
label = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed),
icon = Icons.Rounded.Speed,
onClick = onSpeedClick,
)

View file

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

View file

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

View file

@ -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<Long?>(null) }
var pausedOverlayVisible by remember { mutableStateOf(false) }
var gestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var liveGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var renderedGestureFeedback by remember { mutableStateOf<GestureFeedbackState?>(null) }
var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) }
var speedBoostRestoreSpeed by remember(activeSourceUrl) { mutableStateOf<Float?>(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),
)
}
}
}

View file

@ -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,

View file

@ -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?

View file

@ -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(

View file

@ -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,

View file

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