mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: implement IntroDB submission feature
This commit is contained in:
parent
8a58fabfdd
commit
5c7253d21f
12 changed files with 693 additions and 0 deletions
|
|
@ -45,6 +45,8 @@ actual object PlayerSettingsStorage {
|
||||||
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
||||||
private const val animeSkipEnabledKey = "animeskip_enabled"
|
private const val animeSkipEnabledKey = "animeskip_enabled"
|
||||||
private const val animeSkipClientIdKey = "animeskip_client_id"
|
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 streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
|
||||||
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
||||||
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
||||||
|
|
@ -480,6 +482,33 @@ actual object PlayerSettingsStorage {
|
||||||
?.apply()
|
?.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? =
|
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
|
||||||
preferences?.let { sharedPreferences ->
|
preferences?.let { sharedPreferences ->
|
||||||
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
||||||
|
|
@ -652,6 +681,8 @@ actual object PlayerSettingsStorage {
|
||||||
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
||||||
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
||||||
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
||||||
|
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
|
||||||
|
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
|
||||||
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
||||||
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
||||||
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,10 @@
|
||||||
<string name="settings_playback_anime_skip">Anime Skip</string>
|
<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">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_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_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">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>
|
<string name="settings_playback_auto_play_next_episode_description">Automatically find and play the next episode when the threshold is reached.</string>
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,13 @@ private fun ProgressControls(
|
||||||
onClick = onEpisodesClick,
|
onClick = onEpisodesClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onSubmitIntroClick != null) {
|
||||||
|
PlayerActionPillButton(
|
||||||
|
label = "Submit Intro",
|
||||||
|
icon = Icons.Rounded.Flag,
|
||||||
|
onClick = onSubmitIntroClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ fun PlayerScreen(
|
||||||
// Sources & Episodes Panel state
|
// Sources & Episodes Panel state
|
||||||
var showSourcesPanel by remember { mutableStateOf(false) }
|
var showSourcesPanel by remember { mutableStateOf(false) }
|
||||||
var showEpisodesPanel by remember { mutableStateOf(false) }
|
var showEpisodesPanel by remember { mutableStateOf(false) }
|
||||||
|
var showSubmitIntroModal by remember { mutableStateOf(false) }
|
||||||
var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
|
var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
|
||||||
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
|
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
|
||||||
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
|
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -1599,6 +1600,7 @@ fun PlayerScreen(
|
||||||
},
|
},
|
||||||
onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
|
onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
|
||||||
onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
|
onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
|
||||||
|
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) {{ showSubmitIntroModal = true }} else null,
|
||||||
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
||||||
onScrubFinished = { positionMs ->
|
onScrubFinished = { positionMs ->
|
||||||
scrubbingPositionMs = null
|
scrubbingPositionMs = null
|
||||||
|
|
@ -1844,6 +1846,16 @@ fun PlayerScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSubmitIntroModal && activeSeasonNumber != null && activeEpisodeNumber != null && activeImdbId != null) {
|
||||||
|
com.nuvio.app.features.player.skip.SubmitIntroDialog(
|
||||||
|
imdbId = activeImdbId,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
|
currentTimeSec = (displayedPositionMs / 1000.0),
|
||||||
|
onDismiss = { showSubmitIntroModal = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ object PlayerSettingsRepository {
|
||||||
skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true
|
skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true
|
||||||
animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false
|
animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false
|
||||||
animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: ""
|
animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: ""
|
||||||
|
introDbApiKey = PlayerSettingsStorage.loadIntroDbApiKey() ?: ""
|
||||||
|
introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false
|
||||||
streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false
|
streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false
|
||||||
streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true
|
streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true
|
||||||
nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode()
|
nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode()
|
||||||
|
|
@ -384,6 +386,22 @@ object PlayerSettingsRepository {
|
||||||
PlayerSettingsStorage.saveAnimeSkipClientId(clientId)
|
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) {
|
fun setStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (streamAutoPlayNextEpisodeEnabled == enabled) return
|
if (streamAutoPlayNextEpisodeEnabled == enabled) return
|
||||||
|
|
@ -465,6 +483,8 @@ object PlayerSettingsRepository {
|
||||||
skipIntroEnabled = skipIntroEnabled,
|
skipIntroEnabled = skipIntroEnabled,
|
||||||
animeSkipEnabled = animeSkipEnabled,
|
animeSkipEnabled = animeSkipEnabled,
|
||||||
animeSkipClientId = animeSkipClientId,
|
animeSkipClientId = animeSkipClientId,
|
||||||
|
introDbApiKey = introDbApiKey,
|
||||||
|
introSubmitEnabled = introSubmitEnabled,
|
||||||
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
|
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
|
||||||
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
|
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
|
||||||
nextEpisodeThresholdMode = nextEpisodeThresholdMode,
|
nextEpisodeThresholdMode = nextEpisodeThresholdMode,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,11 @@ internal expect object PlayerSettingsStorage {
|
||||||
fun saveAnimeSkipEnabled(enabled: Boolean)
|
fun saveAnimeSkipEnabled(enabled: Boolean)
|
||||||
fun loadAnimeSkipClientId(): String?
|
fun loadAnimeSkipClientId(): String?
|
||||||
fun saveAnimeSkipClientId(clientId: String)
|
fun saveAnimeSkipClientId(clientId: String)
|
||||||
|
|
||||||
|
fun loadIntroDbApiKey(): String?
|
||||||
|
fun saveIntroDbApiKey(apiKey: String)
|
||||||
|
fun loadIntroSubmitEnabled(): Boolean?
|
||||||
|
fun saveIntroSubmitEnabled(enabled: Boolean)
|
||||||
fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean?
|
fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean?
|
||||||
fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean)
|
fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean)
|
||||||
fun loadStreamAutoPlayPreferBingeGroup(): 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 ---
|
// --- AniSkip ---
|
||||||
|
|
||||||
suspend fun getAniSkipTimes(
|
suspend fun getAniSkipTimes(
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,36 @@ object SkipIntroRepository {
|
||||||
} catch (_: Exception) { emptyList() }.also { imdbEntriesCache[imdbId] = it }
|
} 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() {
|
fun clearCache() {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
imdbEntriesCache.clear()
|
imdbEntriesCache.clear()
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,18 @@ data class IntroDbSegment(
|
||||||
@SerialName("updated_at") val updatedAt: String? = null,
|
@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 ---
|
// --- AniSkip API response models ---
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SubmitIntroDialog(
|
||||||
|
imdbId: String,
|
||||||
|
season: Int,
|
||||||
|
episode: Int,
|
||||||
|
currentTimeSec: Double,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var isSubmitting by remember { mutableStateOf(false) }
|
||||||
|
var segmentType by remember { mutableStateOf("intro") }
|
||||||
|
var startTimeStr by remember { mutableStateOf("00:00") }
|
||||||
|
var endTimeStr by remember { mutableStateOf(formatSecondsToMMSS(currentTimeSec)) }
|
||||||
|
|
||||||
|
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
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, modifier = Modifier.size(24.dp)) {
|
||||||
|
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 = { segmentType = "intro" },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
SegmentTypeButton(
|
||||||
|
label = "Recap",
|
||||||
|
icon = Icons.Rounded.Replay,
|
||||||
|
selected = segmentType == "recap",
|
||||||
|
onClick = { segmentType = "recap" },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
SegmentTypeButton(
|
||||||
|
label = "Outro",
|
||||||
|
icon = Icons.Rounded.StopCircle,
|
||||||
|
selected = segmentType == "outro",
|
||||||
|
onClick = { segmentType = "outro" },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Time
|
||||||
|
TimeInputRow(
|
||||||
|
label = "START TIME (MM:SS)",
|
||||||
|
value = startTimeStr,
|
||||||
|
onValueChange = { startTimeStr = it },
|
||||||
|
onCapture = { startTimeStr = formatSecondsToMMSS(currentTimeSec) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// End Time
|
||||||
|
TimeInputRow(
|
||||||
|
label = "END TIME (MM:SS)",
|
||||||
|
value = endTimeStr,
|
||||||
|
onValueChange = { endTimeStr = it },
|
||||||
|
onCapture = { endTimeStr = 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 {
|
||||||
|
SkipIntroRepository.submitIntro(
|
||||||
|
imdbId = imdbId,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
startSec = start,
|
||||||
|
endSec = end,
|
||||||
|
segmentType = segmentType,
|
||||||
|
)
|
||||||
|
isSubmitting = false
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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.Number),
|
||||||
|
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
|
||||||
|
if (input.contains(':')) {
|
||||||
|
val parts = input.split(':')
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
val mins = parts[0].toIntOrNull() ?: return null
|
||||||
|
val secs = parts[1].toIntOrNull() ?: return null
|
||||||
|
if (secs < 0 || secs >= 60) return null
|
||||||
|
return (mins * 60 + secs).toDouble()
|
||||||
|
}
|
||||||
|
return input.toDoubleOrNull()
|
||||||
|
}
|
||||||
|
|
@ -472,6 +472,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 +1924,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
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
private fun NextEpisodeThresholdModeDialog(
|
private fun NextEpisodeThresholdModeDialog(
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ actual object PlayerSettingsStorage {
|
||||||
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
private const val skipIntroEnabledKey = "skip_intro_enabled"
|
||||||
private const val animeSkipEnabledKey = "animeskip_enabled"
|
private const val animeSkipEnabledKey = "animeskip_enabled"
|
||||||
private const val animeSkipClientIdKey = "animeskip_client_id"
|
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 streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
|
||||||
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
|
||||||
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
|
||||||
|
|
@ -418,6 +420,30 @@ actual object PlayerSettingsStorage {
|
||||||
NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = ProfileScopedKey.of(animeSkipClientIdKey))
|
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? {
|
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? {
|
||||||
val defaults = NSUserDefaults.standardUserDefaults
|
val defaults = NSUserDefaults.standardUserDefaults
|
||||||
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
|
||||||
|
|
@ -559,6 +585,7 @@ actual object PlayerSettingsStorage {
|
||||||
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
|
||||||
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
|
||||||
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
|
||||||
|
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
|
||||||
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
|
||||||
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
|
||||||
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue