mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 23:59:32 +00:00
feat: adding more player gestures - horizontal scrubbing, hold to speed up.
This commit is contained in:
parent
f93091aa1b
commit
2b400cf6fd
10 changed files with 536 additions and 55 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ private fun ProgressControls(
|
|||
onClick = onResizeModeClick,
|
||||
)
|
||||
PlayerActionPillButton(
|
||||
label = "${playbackSnapshot.playbackSpeed}x",
|
||||
label = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed),
|
||||
icon = Icons.Rounded.Speed,
|
||||
onClick = onSpeedClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue