mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 16:01:44 +00:00
Merge branch 'cmp-rewrite' into feat/german
This commit is contained in:
commit
cf15e2008b
13 changed files with 753 additions and 2 deletions
Binary file not shown.
|
|
@ -45,6 +45,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
||||
private const val animeSkipEnabledKey = "animeskip_enabled"
|
||||
private const val animeSkipClientIdKey = "animeskip_client_id"
|
||||
private const val introDbApiKeyKey = "introdb_api_key"
|
||||
private const val introSubmitEnabledKey = "intro_submit_enabled"
|
||||
private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
|
||||
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
||||
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
||||
|
|
@ -480,6 +482,33 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadIntroDbApiKey(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(introDbApiKeyKey), null)
|
||||
|
||||
actual fun saveIntroDbApiKey(apiKey: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(introDbApiKeyKey), apiKey)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadIntroSubmitEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(introSubmitEnabledKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveIntroSubmitEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(introSubmitEnabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
||||
|
|
@ -652,6 +681,8 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
||||
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
||||
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
||||
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
|
||||
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
|
||||
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
||||
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
||||
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
||||
|
|
|
|||
|
|
@ -621,6 +621,10 @@
|
|||
<string name="settings_playback_anime_skip">Anime Skip</string>
|
||||
<string name="settings_playback_anime_skip_client_id">AnimeSkip Client ID</string>
|
||||
<string name="settings_playback_anime_skip_client_id_description">Enter your AnimeSkip API client ID. Get one at anime-skip.com.</string>
|
||||
<string name="settings_playback_intro_submit_enabled">Enable Intro Submission</string>
|
||||
<string name="settings_playback_intro_submit_enabled_description">Show a button to submit intro/outro timestamps to the community database.</string>
|
||||
<string name="settings_playback_introdb_api_key">IntroDB API Key</string>
|
||||
<string name="settings_playback_introdb_api_key_description">Enter your IntroDB API key to submit timestamps. Required for submission.</string>
|
||||
<string name="settings_playback_anime_skip_description">Also search AnimeSkip for skip timestamps (requires client ID).</string>
|
||||
<string name="settings_playback_auto_play_next_episode">Auto-Play Next Episode</string>
|
||||
<string name="settings_playback_auto_play_next_episode_description">Automatically find and play the next episode when the threshold is reached.</string>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Forward10
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material.icons.rounded.LockOpen
|
||||
|
|
@ -79,6 +80,7 @@ internal fun PlayerControlsShell(
|
|||
onAudioClick: () -> Unit,
|
||||
onSourcesClick: (() -> Unit)? = null,
|
||||
onEpisodesClick: (() -> Unit)? = null,
|
||||
onSubmitIntroClick: (() -> Unit)? = null,
|
||||
onScrubChange: (Long) -> Unit,
|
||||
onScrubFinished: (Long) -> Unit,
|
||||
horizontalSafePadding: androidx.compose.ui.unit.Dp,
|
||||
|
|
@ -166,6 +168,7 @@ internal fun PlayerControlsShell(
|
|||
onAudioClick = onAudioClick,
|
||||
onSourcesClick = onSourcesClick,
|
||||
onEpisodesClick = onEpisodesClick,
|
||||
onSubmitIntroClick = onSubmitIntroClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
|
|
@ -421,6 +424,7 @@ private fun ProgressControls(
|
|||
onAudioClick: () -> Unit,
|
||||
onSourcesClick: (() -> Unit)? = null,
|
||||
onEpisodesClick: (() -> Unit)? = null,
|
||||
onSubmitIntroClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
||||
|
|
@ -502,6 +506,13 @@ private fun ProgressControls(
|
|||
onClick = onEpisodesClick,
|
||||
)
|
||||
}
|
||||
if (onSubmitIntroClick != null) {
|
||||
PlayerActionPillButton(
|
||||
label = "Submit Intro",
|
||||
icon = Icons.Rounded.Flag,
|
||||
onClick = onSubmitIntroClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,6 +241,10 @@ fun PlayerScreen(
|
|||
// Sources & Episodes Panel state
|
||||
var showSourcesPanel by remember { mutableStateOf(false) }
|
||||
var showEpisodesPanel by remember { mutableStateOf(false) }
|
||||
var showSubmitIntroModal by remember { mutableStateOf(false) }
|
||||
var submitIntroSegmentType by rememberSaveable { mutableStateOf("intro") }
|
||||
var submitIntroStartTimeStr by rememberSaveable { mutableStateOf("00:00") }
|
||||
var submitIntroEndTimeStr by rememberSaveable { mutableStateOf("00:00") }
|
||||
var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
|
||||
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
|
||||
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
|
||||
|
|
@ -1602,8 +1606,9 @@ fun PlayerScreen(
|
|||
refreshTracks()
|
||||
showAudioModal = true
|
||||
},
|
||||
onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
|
||||
onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
|
||||
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
|
||||
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
|
||||
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
|
||||
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
||||
onScrubFinished = { positionMs ->
|
||||
scrubbingPositionMs = null
|
||||
|
|
@ -1849,6 +1854,34 @@ fun PlayerScreen(
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
val season = activeSeasonNumber
|
||||
val episode = activeEpisodeNumber
|
||||
val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
|
||||
?: parentMetaId.takeIf { it.startsWith("tt") }
|
||||
?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
|
||||
|
||||
if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) {
|
||||
com.nuvio.app.features.player.skip.SubmitIntroDialog(
|
||||
imdbId = imdbId,
|
||||
season = season,
|
||||
episode = episode,
|
||||
currentTimeSec = (displayedPositionMs / 1000.0),
|
||||
segmentType = submitIntroSegmentType,
|
||||
onSegmentTypeChange = { submitIntroSegmentType = it },
|
||||
startTimeStr = submitIntroStartTimeStr,
|
||||
onStartTimeChange = { submitIntroStartTimeStr = it },
|
||||
endTimeStr = submitIntroEndTimeStr,
|
||||
onEndTimeChange = { submitIntroEndTimeStr = it },
|
||||
onDismiss = { showSubmitIntroModal = false },
|
||||
onSuccess = {
|
||||
submitIntroStartTimeStr = "00:00"
|
||||
submitIntroEndTimeStr = "00:00"
|
||||
submitIntroSegmentType = "intro"
|
||||
showSubmitIntroModal = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ data class PlayerSettingsUiState(
|
|||
val skipIntroEnabled: Boolean = true,
|
||||
val animeSkipEnabled: Boolean = false,
|
||||
val animeSkipClientId: String = "",
|
||||
val introDbApiKey: String = "",
|
||||
val introSubmitEnabled: Boolean = false,
|
||||
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
|
||||
val streamAutoPlayPreferBingeGroup: Boolean = true,
|
||||
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
|
||||
|
|
@ -69,6 +71,8 @@ object PlayerSettingsRepository {
|
|||
private var skipIntroEnabled = true
|
||||
private var animeSkipEnabled = false
|
||||
private var animeSkipClientId = ""
|
||||
private var introDbApiKey = ""
|
||||
private var introSubmitEnabled = false
|
||||
private var streamAutoPlayNextEpisodeEnabled = false
|
||||
private var streamAutoPlayPreferBingeGroup = true
|
||||
private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
|
||||
|
|
@ -111,6 +115,8 @@ object PlayerSettingsRepository {
|
|||
skipIntroEnabled = true
|
||||
animeSkipEnabled = false
|
||||
animeSkipClientId = ""
|
||||
introDbApiKey = ""
|
||||
introSubmitEnabled = false
|
||||
streamAutoPlayNextEpisodeEnabled = false
|
||||
streamAutoPlayPreferBingeGroup = true
|
||||
nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
|
||||
|
|
@ -178,6 +184,8 @@ object PlayerSettingsRepository {
|
|||
skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true
|
||||
animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false
|
||||
animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: ""
|
||||
introDbApiKey = PlayerSettingsStorage.loadIntroDbApiKey() ?: ""
|
||||
introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false
|
||||
streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false
|
||||
streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true
|
||||
nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode()
|
||||
|
|
@ -384,6 +392,22 @@ object PlayerSettingsRepository {
|
|||
PlayerSettingsStorage.saveAnimeSkipClientId(clientId)
|
||||
}
|
||||
|
||||
fun setIntroDbApiKey(apiKey: String) {
|
||||
ensureLoaded()
|
||||
if (introDbApiKey == apiKey) return
|
||||
introDbApiKey = apiKey
|
||||
publish()
|
||||
PlayerSettingsStorage.saveIntroDbApiKey(apiKey)
|
||||
}
|
||||
|
||||
fun setIntroSubmitEnabled(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (introSubmitEnabled == enabled) return
|
||||
introSubmitEnabled = enabled
|
||||
publish()
|
||||
PlayerSettingsStorage.saveIntroSubmitEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (streamAutoPlayNextEpisodeEnabled == enabled) return
|
||||
|
|
@ -465,6 +489,8 @@ object PlayerSettingsRepository {
|
|||
skipIntroEnabled = skipIntroEnabled,
|
||||
animeSkipEnabled = animeSkipEnabled,
|
||||
animeSkipClientId = animeSkipClientId,
|
||||
introDbApiKey = introDbApiKey,
|
||||
introSubmitEnabled = introSubmitEnabled,
|
||||
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
|
||||
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
|
||||
nextEpisodeThresholdMode = nextEpisodeThresholdMode,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ internal expect object PlayerSettingsStorage {
|
|||
fun saveAnimeSkipEnabled(enabled: Boolean)
|
||||
fun loadAnimeSkipClientId(): String?
|
||||
fun saveAnimeSkipClientId(clientId: String)
|
||||
|
||||
fun loadIntroDbApiKey(): String?
|
||||
fun saveIntroDbApiKey(apiKey: String)
|
||||
fun loadIntroSubmitEnabled(): Boolean?
|
||||
fun saveIntroSubmitEnabled(enabled: Boolean)
|
||||
fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean?
|
||||
fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean)
|
||||
fun loadStreamAutoPlayPreferBingeGroup(): Boolean?
|
||||
|
|
|
|||
|
|
@ -30,6 +30,62 @@ internal object SkipIntroApi {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun submitIntro(
|
||||
apiKey: String,
|
||||
request: SubmitIntroRequest,
|
||||
): Boolean {
|
||||
val baseUrl = IntroDbConfig.URL.trimEnd('/')
|
||||
if (baseUrl.isBlank() || apiKey.isBlank()) return false
|
||||
val url = "$baseUrl/submit"
|
||||
val body = json.encodeToString(SubmitIntroRequest.serializer(), request)
|
||||
val headers = mapOf(
|
||||
"Authorization" to "Bearer $apiKey",
|
||||
"Content-Type" to "application/json"
|
||||
)
|
||||
return try {
|
||||
val response = com.nuvio.app.features.addons.httpRequestRaw(
|
||||
method = "POST",
|
||||
url = url,
|
||||
headers = headers,
|
||||
body = body
|
||||
)
|
||||
response.status == 200 || response.status == 201
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyIntroDbApiKey(apiKey: String): Boolean {
|
||||
val baseUrl = IntroDbConfig.URL.trimEnd('/')
|
||||
if (baseUrl.isBlank() || apiKey.isBlank()) return false
|
||||
val url = "$baseUrl/submit"
|
||||
val headers = mapOf(
|
||||
"Authorization" to "Bearer $apiKey",
|
||||
"Content-Type" to "application/json"
|
||||
)
|
||||
return try {
|
||||
val response = com.nuvio.app.features.addons.httpRequestRaw(
|
||||
method = "POST",
|
||||
url = url,
|
||||
headers = headers,
|
||||
body = "{}"
|
||||
)
|
||||
|
||||
// 400 means Auth passed but payload was empty/invalid -> Key is Valid
|
||||
if (response.status == 400) return true
|
||||
|
||||
// 200/201 would also mean valid (though unexpected with empty body)
|
||||
if (response.status == 200 || response.status == 201) return true
|
||||
|
||||
// Explicitly handle auth failures
|
||||
if (response.status == 401 || response.status == 403) return false
|
||||
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// --- AniSkip ---
|
||||
|
||||
suspend fun getAniSkipTimes(
|
||||
|
|
|
|||
|
|
@ -241,6 +241,36 @@ object SkipIntroRepository {
|
|||
} catch (_: Exception) { emptyList() }.also { imdbEntriesCache[imdbId] = it }
|
||||
}
|
||||
|
||||
suspend fun submitIntro(
|
||||
imdbId: String,
|
||||
season: Int,
|
||||
episode: Int,
|
||||
startSec: Double,
|
||||
endSec: Double,
|
||||
segmentType: String,
|
||||
): Boolean {
|
||||
val settings = PlayerSettingsRepository.uiState.value
|
||||
val apiKey = settings.introDbApiKey.trim()
|
||||
if (!settings.introSubmitEnabled || apiKey.isBlank()) return false
|
||||
|
||||
val request = SubmitIntroRequest(
|
||||
imdbId = imdbId,
|
||||
season = season,
|
||||
episode = episode,
|
||||
startSec = startSec,
|
||||
endSec = endSec,
|
||||
startMs = (startSec * 1000).toLong(),
|
||||
endMs = (endSec * 1000).toLong(),
|
||||
segmentType = segmentType,
|
||||
)
|
||||
|
||||
return SkipIntroApi.submitIntro(apiKey, request)
|
||||
}
|
||||
|
||||
suspend fun verifyIntroDbApiKey(apiKey: String): Boolean {
|
||||
return SkipIntroApi.verifyIntroDbApiKey(apiKey)
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cache.clear()
|
||||
imdbEntriesCache.clear()
|
||||
|
|
|
|||
|
|
@ -50,6 +50,18 @@ data class IntroDbSegment(
|
|||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubmitIntroRequest(
|
||||
@SerialName("imdb_id") val imdbId: String,
|
||||
@SerialName("season") val season: Int,
|
||||
@SerialName("episode") val episode: Int,
|
||||
@SerialName("start_sec") val startSec: Double,
|
||||
@SerialName("end_sec") val endSec: Double,
|
||||
@SerialName("start_ms") val startMs: Long,
|
||||
@SerialName("end_ms") val endMs: Long,
|
||||
@SerialName("segment_type") val segmentType: String,
|
||||
)
|
||||
|
||||
// --- AniSkip API response models ---
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
package com.nuvio.app.features.player.skip
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.GpsFixed
|
||||
import androidx.compose.material.icons.rounded.PlayCircleOutline
|
||||
import androidx.compose.material.icons.rounded.Replay
|
||||
import androidx.compose.material.icons.rounded.Send
|
||||
import androidx.compose.material.icons.rounded.StopCircle
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.floor
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SubmitIntroDialog(
|
||||
imdbId: String,
|
||||
season: Int,
|
||||
episode: Int,
|
||||
currentTimeSec: Double,
|
||||
segmentType: String,
|
||||
onSegmentTypeChange: (String) -> Unit,
|
||||
startTimeStr: String,
|
||||
onStartTimeChange: (String) -> Unit,
|
||||
endTimeStr: String,
|
||||
onEndTimeChange: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
var isSubmitting by remember { mutableStateOf(false) }
|
||||
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Submit Timestamps",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
// Segment Type
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "SEGMENT TYPE",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SegmentTypeButton(
|
||||
label = "Intro",
|
||||
icon = Icons.Rounded.PlayCircleOutline,
|
||||
selected = segmentType == "intro",
|
||||
onClick = { onSegmentTypeChange("intro") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SegmentTypeButton(
|
||||
label = "Recap",
|
||||
icon = Icons.Rounded.Replay,
|
||||
selected = segmentType == "recap",
|
||||
onClick = { onSegmentTypeChange("recap") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SegmentTypeButton(
|
||||
label = "Outro",
|
||||
icon = Icons.Rounded.StopCircle,
|
||||
selected = segmentType == "outro",
|
||||
onClick = { onSegmentTypeChange("outro") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Start Time
|
||||
TimeInputRow(
|
||||
label = "START TIME (MM:SS)",
|
||||
value = startTimeStr,
|
||||
onValueChange = onStartTimeChange,
|
||||
onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) }
|
||||
)
|
||||
|
||||
// End Time
|
||||
TimeInputRow(
|
||||
label = "END TIME (MM:SS)",
|
||||
value = endTimeStr,
|
||||
onValueChange = onEndTimeChange,
|
||||
onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = !isSubmitting, onClick = onDismiss),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.clickable(enabled = !isSubmitting) {
|
||||
val start = parseTimeToSeconds(startTimeStr)
|
||||
val end = parseTimeToSeconds(endTimeStr)
|
||||
if (start != null && end != null && end > start) {
|
||||
isSubmitting = true
|
||||
scope.launch {
|
||||
val result = SkipIntroRepository.submitIntro(
|
||||
imdbId = imdbId,
|
||||
season = season,
|
||||
episode = episode,
|
||||
startSec = start,
|
||||
endSec = end,
|
||||
segmentType = segmentType,
|
||||
)
|
||||
isSubmitting = false
|
||||
if (result) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSubmitting) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
text = "Submit",
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SegmentTypeButton(
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeInputRow(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onCapture: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onCapture)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.GpsFixed,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Capture",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSecondsToMMSS(seconds: Double): String {
|
||||
val mins = floor(seconds / 60).toInt()
|
||||
val secs = floor(seconds % 60).toInt()
|
||||
return "${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private fun parseTimeToSeconds(input: String): Double? {
|
||||
if (input.isBlank()) return null
|
||||
|
||||
// Check for separator (colon or dot)
|
||||
val separator = when {
|
||||
input.contains(':') -> ":"
|
||||
input.contains('.') -> "."
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (separator != null) {
|
||||
val parts = input.split(separator)
|
||||
if (parts.size == 2) {
|
||||
val mins = parts[0].toIntOrNull() ?: return null
|
||||
val secs = parts[1].toIntOrNull() ?: return null
|
||||
// If the user uses a dot, we assume they mean MM.SS (e.g. 1.24 = 1m 24s)
|
||||
// But we only treat it as minutes if seconds are 0-59.
|
||||
if (secs in 0..59) {
|
||||
return (mins * 60 + secs).toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return input.toDoubleOrNull()
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -36,6 +37,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -58,6 +60,7 @@ import com.nuvio.app.features.plugins.PluginRepository
|
|||
import com.nuvio.app.features.streams.StreamAutoPlayMode
|
||||
import com.nuvio.app.features.streams.StreamAutoPlaySource
|
||||
import com.nuvio.app.isIos
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -472,6 +475,35 @@ private fun PlaybackSettingsSection(
|
|||
)
|
||||
}
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_intro_submit_enabled),
|
||||
description = stringResource(Res.string.settings_playback_intro_submit_enabled_description),
|
||||
checked = autoPlayPlayerSettings.introSubmitEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setIntroSubmitEnabled,
|
||||
)
|
||||
if (autoPlayPlayerSettings.introSubmitEnabled) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
var showIntroDbApiKeyDialog by remember { mutableStateOf(false) }
|
||||
val notSetLabel = stringResource(Res.string.settings_playback_not_set)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_introdb_api_key),
|
||||
description = autoPlayPlayerSettings.introDbApiKey.ifBlank { notSetLabel },
|
||||
isTablet = isTablet,
|
||||
onClick = { showIntroDbApiKeyDialog = true },
|
||||
)
|
||||
if (showIntroDbApiKeyDialog) {
|
||||
IntroDbApiKeyDialog(
|
||||
initialValue = autoPlayPlayerSettings.introDbApiKey,
|
||||
onSave = {
|
||||
PlayerSettingsRepository.setIntroDbApiKey(it)
|
||||
showIntroDbApiKeyDialog = false
|
||||
},
|
||||
onDismiss = { showIntroDbApiKeyDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1895,6 +1927,118 @@ private fun AnimeSkipClientIdDialog(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun IntroDbApiKeyDialog(
|
||||
initialValue: String,
|
||||
onSave: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
var isVerifying by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BasicAlertDialog(onDismissRequest = { if (!isVerifying) 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_introdb_api_key),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_introdb_api_key_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (errorMessage != null) 1f else 0.3f)),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
errorMessage = null
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss, enabled = !isVerifying) {
|
||||
Text(stringResource(Res.string.action_cancel))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
onSave(trimmed)
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
if (trimmed == initialValue) {
|
||||
onDismiss()
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
isVerifying = true
|
||||
errorMessage = null
|
||||
scope.launch {
|
||||
val isValid = com.nuvio.app.features.player.skip.SkipIntroRepository.verifyIntroDbApiKey(trimmed)
|
||||
isVerifying = false
|
||||
if (isValid) {
|
||||
onSave(trimmed)
|
||||
} else {
|
||||
errorMessage = "Invalid API Key or connection failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isVerifying
|
||||
) {
|
||||
if (isVerifying) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(Res.string.action_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun NextEpisodeThresholdModeDialog(
|
||||
|
|
@ -2018,3 +2162,4 @@ private fun libassRenderTypeRes(renderType: String): StringResource = when (rend
|
|||
|
||||
@Composable
|
||||
private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType))
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ actual object PlayerSettingsStorage {
|
|||
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
||||
private const val animeSkipEnabledKey = "animeskip_enabled"
|
||||
private const val animeSkipClientIdKey = "animeskip_client_id"
|
||||
private const val introDbApiKeyKey = "introdb_api_key"
|
||||
private const val introSubmitEnabledKey = "intro_submit_enabled"
|
||||
private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
|
||||
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
||||
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
||||
|
|
@ -418,6 +420,30 @@ actual object PlayerSettingsStorage {
|
|||
NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = ProfileScopedKey.of(animeSkipClientIdKey))
|
||||
}
|
||||
|
||||
actual fun loadIntroDbApiKey(): String? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(introDbApiKeyKey)
|
||||
return defaults.stringForKey(key)
|
||||
}
|
||||
|
||||
actual fun saveIntroDbApiKey(apiKey: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(apiKey, forKey = ProfileScopedKey.of(introDbApiKeyKey))
|
||||
}
|
||||
|
||||
actual fun loadIntroSubmitEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(introSubmitEnabledKey)
|
||||
return if (defaults.objectForKey(key) != null) {
|
||||
defaults.boolForKey(key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveIntroSubmitEnabled(enabled: Boolean) {
|
||||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(introSubmitEnabledKey))
|
||||
}
|
||||
|
||||
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
||||
|
|
@ -559,6 +585,7 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
||||
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
||||
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
||||
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
|
||||
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
||||
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
||||
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
||||
|
|
|
|||
Loading…
Reference in a new issue