feat: implement swipe-to-seek toggle and sensitivity settings (#936)

This commit is contained in:
MukeshCheekatla 2026-05-08 14:14:32 +05:30
parent 11a1cf7ba9
commit 766970bce7
8 changed files with 264 additions and 3 deletions

View file

@ -54,6 +54,8 @@ actual object PlayerSettingsStorage {
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type"
private const val swipeToSeekEnabledKey = "swipe_to_seek_enabled"
private const val swipeToSeekSensitivityKey = "swipe_to_seek_sensitivity"
private val syncKeys = listOf(
showLoadingOverlayKey,
resizeModeKey,
@ -88,6 +90,8 @@ actual object PlayerSettingsStorage {
nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey,
libassRenderTypeKey,
swipeToSeekEnabledKey,
swipeToSeekSensitivityKey,
)
private var preferences: SharedPreferences? = null
@ -648,6 +652,8 @@ actual object PlayerSettingsStorage {
loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) }
loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) }
loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) }
loadSwipeToSeekEnabled()?.let { put(swipeToSeekEnabledKey, encodeSyncBoolean(it)) }
loadSwipeToSeekSensitivity()?.let { put(swipeToSeekSensitivityKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -690,5 +696,34 @@ actual object PlayerSettingsStorage {
payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd)
payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass)
payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType)
payload.decodeSyncBoolean(swipeToSeekEnabledKey)?.let(::saveSwipeToSeekEnabled)
payload.decodeSyncString(swipeToSeekSensitivityKey)?.let(::saveSwipeToSeekSensitivity)
}
actual fun loadSwipeToSeekEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(swipeToSeekEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, true)
} else {
null
}
}
actual fun saveSwipeToSeekEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(swipeToSeekEnabledKey), enabled)
?.apply()
}
actual fun loadSwipeToSeekSensitivity(): String? =
preferences?.getString(ProfileScopedKey.of(swipeToSeekSensitivityKey), null)
actual fun saveSwipeToSeekSensitivity(sensitivity: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(swipeToSeekSensitivityKey), sensitivity)
?.apply()
}
}

View file

@ -678,6 +678,13 @@
<string name="settings_playback_regex_matches_against">Matches against stream name/title/description/addon/url. Example: 4K|2160p|Remux</string>
<string name="settings_playback_regex_pattern">Regex Pattern</string>
<string name="settings_playback_regex_placeholder">No pattern set. Example: 4K|2160p|Remux</string>
<string name="settings_playback_swipe_to_seek">Swipe to Seek</string>
<string name="settings_playback_swipe_to_seek_description">Swipe horizontally on the player surface to seek forward or backward.</string>
<string name="settings_playback_swipe_to_seek_sensitivity">Swipe to Seek Sensitivity</string>
<string name="settings_playback_swipe_to_seek_sensitivity_description">Adjust the drag distance required to trigger a seek.</string>
<string name="settings_playback_swipe_to_seek_sensitivity_low">Low</string>
<string name="settings_playback_swipe_to_seek_sensitivity_medium">Medium</string>
<string name="settings_playback_swipe_to_seek_sensitivity_high">High</string>
<string name="settings_playback_regex_preset_any_1080p">Any 1080p+</string>
<string name="settings_playback_regex_preset_avc_x264">AVC / x264</string>
<string name="settings_playback_regex_preset_bluray_quality">BluRay Quality</string>

View file

@ -1450,8 +1450,9 @@ fun PlayerScreen(
if (gestureMode == null) {
val horizontalDominant =
!isHoldToSpeedGestureActiveState.value &&
abs(totalDx) > viewConfiguration.touchSlop &&
playerSettingsUiState.swipeToSeekEnabled &&
!isHoldToSpeedGestureActiveState.value &&
abs(totalDx) > viewConfiguration.touchSlop * playerSettingsUiState.swipeToSeekSensitivity.triggerMultiplier &&
abs(totalDx) > abs(totalDy)
val verticalDominant =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
@ -1486,7 +1487,7 @@ fun PlayerScreen(
else -> 60f
}
val previewOffsetMs =
((totalDx / width) * sensitivitySeconds * 1000f).roundToLong()
((totalDx / width) * sensitivitySeconds * playerSettingsUiState.swipeToSeekSensitivity.speedMultiplier * 1000f).roundToLong()
val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs
horizontalSeekPreviewMs = currentDurationMsState.value
.takeIf { it > 0L }

View file

@ -8,6 +8,24 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class SwipeToSeekSensitivity {
LOW, MEDIUM, HIGH;
val triggerMultiplier: Float
get() = when (this) {
LOW -> 4.0f
MEDIUM -> 1.0f
HIGH -> 0.5f
}
val speedMultiplier: Float
get() = when (this) {
LOW -> 0.5f
MEDIUM -> 1.0f
HIGH -> 2.0f
}
}
data class PlayerSettingsUiState(
val showLoadingOverlay: Boolean = true,
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
@ -29,6 +47,8 @@ data class PlayerSettingsUiState(
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
val streamAutoPlayRegex: String = "",
val streamAutoPlayTimeoutSeconds: Int = 3,
val swipeToSeekEnabled: Boolean = true,
val swipeToSeekSensitivity: SwipeToSeekSensitivity = SwipeToSeekSensitivity.MEDIUM,
val skipIntroEnabled: Boolean = true,
val animeSkipEnabled: Boolean = false,
val animeSkipClientId: String = "",
@ -80,6 +100,8 @@ object PlayerSettingsRepository {
private var nextEpisodeThresholdMinutesBeforeEnd = 2f
private var useLibass = false
private var libassRenderType = "CUES"
private var swipeToSeekEnabled = true
private var swipeToSeekSensitivity = SwipeToSeekSensitivity.MEDIUM
fun ensureLoaded() {
if (hasLoaded) return
@ -195,6 +217,12 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f
useLibass = PlayerSettingsStorage.loadUseLibass() ?: false
libassRenderType = PlayerSettingsStorage.loadLibassRenderType() ?: "CUES"
swipeToSeekEnabled = PlayerSettingsStorage.loadSwipeToSeekEnabled() ?: true
swipeToSeekSensitivity = try {
SwipeToSeekSensitivity.valueOf(PlayerSettingsStorage.loadSwipeToSeekSensitivity() ?: "MEDIUM")
} catch (e: Exception) {
SwipeToSeekSensitivity.MEDIUM
}
publish()
}
@ -464,6 +492,22 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveLibassRenderType(renderType)
}
fun setSwipeToSeekEnabled(enabled: Boolean) {
ensureLoaded()
if (swipeToSeekEnabled == enabled) return
swipeToSeekEnabled = enabled
publish()
PlayerSettingsStorage.saveSwipeToSeekEnabled(enabled)
}
fun setSwipeToSeekSensitivity(sensitivity: SwipeToSeekSensitivity) {
ensureLoaded()
if (swipeToSeekSensitivity == sensitivity) return
swipeToSeekSensitivity = sensitivity
publish()
PlayerSettingsStorage.saveSwipeToSeekSensitivity(sensitivity.name)
}
private fun publish() {
_uiState.value = PlayerSettingsUiState(
showLoadingOverlay = showLoadingOverlay,
@ -498,6 +542,8 @@ object PlayerSettingsRepository {
nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd,
useLibass = useLibass,
libassRenderType = libassRenderType,
swipeToSeekEnabled = swipeToSeekEnabled,
swipeToSeekSensitivity = swipeToSeekSensitivity,
)
}

View file

@ -74,6 +74,10 @@ internal expect object PlayerSettingsStorage {
fun saveUseLibass(enabled: Boolean)
fun loadLibassRenderType(): String?
fun saveLibassRenderType(renderType: String)
fun loadSwipeToSeekEnabled(): Boolean?
fun saveSwipeToSeekEnabled(enabled: Boolean)
fun loadSwipeToSeekSensitivity(): String?
fun saveSwipeToSeekSensitivity(sensitivity: String)
fun exportToSyncPayload(): JsonObject
fun replaceFromSyncPayload(payload: JsonObject)
}

View file

@ -54,6 +54,7 @@ import com.nuvio.app.features.addons.AddonRepository
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.SwipeToSeekSensitivity
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
import com.nuvio.app.features.player.languageLabelForCode
@ -84,6 +85,8 @@ internal fun LazyListScope.playbackSettingsContent(
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
swipeToSeekEnabled: Boolean,
swipeToSeekSensitivity: SwipeToSeekSensitivity,
) {
item {
PlaybackSettingsSection(
@ -102,6 +105,8 @@ internal fun LazyListScope.playbackSettingsContent(
tunnelingEnabled = tunnelingEnabled,
useLibass = useLibass,
libassRenderType = libassRenderType,
swipeToSeekEnabled = swipeToSeekEnabled,
swipeToSeekSensitivity = swipeToSeekSensitivity,
)
}
}
@ -164,6 +169,8 @@ private fun PlaybackSettingsSection(
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
swipeToSeekEnabled: Boolean,
swipeToSeekSensitivity: SwipeToSeekSensitivity,
) {
var showPreferredAudioDialog by remember { mutableStateOf(false) }
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
@ -178,6 +185,7 @@ private fun PlaybackSettingsSection(
var showAutoPlayAddonSelectionDialog by remember { mutableStateOf(false) }
var showAutoPlayPluginSelectionDialog by remember { mutableStateOf(false) }
var showAutoPlayRegexDialog by remember { mutableStateOf(false) }
var showSwipeSensitivityDialog by remember { mutableStateOf(false) }
val pluginsEnabled = AppFeaturePolicy.pluginsEnabled
val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
val addonUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
@ -222,6 +230,27 @@ private fun PlaybackSettingsSection(
onClick = { showHoldToSpeedValueDialog = true },
)
}
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_swipe_to_seek),
description = stringResource(Res.string.settings_playback_swipe_to_seek_description),
checked = swipeToSeekEnabled,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setSwipeToSeekEnabled,
)
if (swipeToSeekEnabled) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity),
description = when (swipeToSeekSensitivity) {
SwipeToSeekSensitivity.LOW -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_low)
SwipeToSeekSensitivity.MEDIUM -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_medium)
SwipeToSeekSensitivity.HIGH -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_high)
},
isTablet = isTablet,
onClick = { showSwipeSensitivityDialog = true },
)
}
}
}
@ -813,6 +842,14 @@ private fun PlaybackSettingsSection(
)
}
if (showSwipeSensitivityDialog) {
SwipeToSeekSensitivityDialog(
selected = swipeToSeekSensitivity,
onSelect = PlayerSettingsRepository::setSwipeToSeekSensitivity,
onDismiss = { showSwipeSensitivityDialog = false },
)
}
if (showAutoPlayModeDialog) {
StreamAutoPlayModeDialog(
selectedMode = autoPlayPlayerSettings.streamAutoPlayMode,
@ -2178,3 +2215,89 @@ private fun libassRenderTypeRes(renderType: String): StringResource = when (rend
@Composable
private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType))
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SwipeToSeekSensitivityDialog(
selected: SwipeToSeekSensitivity,
onSelect: (SwipeToSeekSensitivity) -> Unit,
onDismiss: () -> Unit,
) {
val options = SwipeToSeekSensitivity.entries
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 = stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { option ->
val isSelected = option == selected
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSelect(option)
onDismiss()
},
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = when (option) {
SwipeToSeekSensitivity.LOW -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_low)
SwipeToSeekSensitivity.MEDIUM -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_medium)
SwipeToSeekSensitivity.HIGH -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_high)
else -> stringResource(Res.string.settings_playback_swipe_to_seek_sensitivity_medium)
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}

View file

@ -55,6 +55,9 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.PlayerResizeMode
import com.nuvio.app.features.player.SwipeToSeekSensitivity
import com.nuvio.app.features.player.skip.NextEpisodeThresholdMode
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
@ -193,6 +196,8 @@ fun SettingsScreen(
tunnelingEnabled = playerSettingsUiState.tunnelingEnabled,
useLibass = playerSettingsUiState.useLibass,
libassRenderType = playerSettingsUiState.libassRenderType,
swipeToSeekEnabled = playerSettingsUiState.swipeToSeekEnabled,
swipeToSeekSensitivity = playerSettingsUiState.swipeToSeekSensitivity,
selectedTheme = selectedTheme,
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
@ -238,6 +243,8 @@ fun SettingsScreen(
tunnelingEnabled = playerSettingsUiState.tunnelingEnabled,
useLibass = playerSettingsUiState.useLibass,
libassRenderType = playerSettingsUiState.libassRenderType,
swipeToSeekEnabled = playerSettingsUiState.swipeToSeekEnabled,
swipeToSeekSensitivity = playerSettingsUiState.swipeToSeekSensitivity,
selectedTheme = selectedTheme,
onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled,
@ -293,6 +300,8 @@ private fun MobileSettingsScreen(
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
swipeToSeekEnabled: Boolean,
swipeToSeekSensitivity: SwipeToSeekSensitivity,
selectedTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
@ -374,6 +383,8 @@ private fun MobileSettingsScreen(
tunnelingEnabled = tunnelingEnabled,
useLibass = useLibass,
libassRenderType = libassRenderType,
swipeToSeekEnabled = swipeToSeekEnabled,
swipeToSeekSensitivity = swipeToSeekSensitivity,
)
SettingsPage.Appearance -> appearanceSettingsContent(
isTablet = false,
@ -471,6 +482,8 @@ private fun TabletSettingsScreen(
tunnelingEnabled: Boolean,
useLibass: Boolean,
libassRenderType: String,
swipeToSeekEnabled: Boolean,
swipeToSeekSensitivity: SwipeToSeekSensitivity,
selectedTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean,
@ -624,6 +637,8 @@ private fun TabletSettingsScreen(
tunnelingEnabled = tunnelingEnabled,
useLibass = useLibass,
libassRenderType = libassRenderType,
swipeToSeekEnabled = swipeToSeekEnabled,
swipeToSeekSensitivity = swipeToSeekSensitivity,
)
SettingsPage.Appearance -> appearanceSettingsContent(
isTablet = true,

View file

@ -52,6 +52,8 @@ actual object PlayerSettingsStorage {
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private const val useLibassKey = "use_libass"
private const val libassRenderTypeKey = "libass_render_type"
private const val swipeToSeekEnabledKey = "swipe_to_seek_enabled"
private const val swipeToSeekSensitivityKey = "swipe_to_seek_sensitivity"
private val syncKeys = listOf(
showLoadingOverlayKey,
resizeModeKey,
@ -86,6 +88,8 @@ actual object PlayerSettingsStorage {
nextEpisodeThresholdMinutesBeforeEndKey,
useLibassKey,
libassRenderTypeKey,
swipeToSeekEnabledKey,
swipeToSeekSensitivityKey,
)
actual fun loadShowLoadingOverlay(): Boolean? {
@ -552,6 +556,8 @@ actual object PlayerSettingsStorage {
loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) }
loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) }
loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) }
loadSwipeToSeekEnabled()?.let { put(swipeToSeekEnabledKey, encodeSyncBoolean(it)) }
loadSwipeToSeekSensitivity()?.let { put(swipeToSeekSensitivityKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -593,5 +599,29 @@ actual object PlayerSettingsStorage {
payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd)
payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass)
payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType)
payload.decodeSyncBoolean(swipeToSeekEnabledKey)?.let(::saveSwipeToSeekEnabled)
payload.decodeSyncString(swipeToSeekSensitivityKey)?.let(::saveSwipeToSeekSensitivity)
}
actual fun loadSwipeToSeekEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(swipeToSeekEnabledKey)
return if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
null
}
}
actual fun saveSwipeToSeekEnabled(enabled: Boolean) {
val defaults = NSUserDefaults.standardUserDefaults
defaults.setBool(enabled, ProfileScopedKey.of(swipeToSeekEnabledKey))
}
actual fun loadSwipeToSeekSensitivity(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(swipeToSeekSensitivityKey))
actual fun saveSwipeToSeekSensitivity(sensitivity: String) {
NSUserDefaults.standardUserDefaults.setObject(sensitivity, ProfileScopedKey.of(swipeToSeekSensitivityKey))
}
}