feat: implement IntroDB submission feature

This commit is contained in:
paregi12 2026-05-02 11:27:47 +05:30
parent 8a58fabfdd
commit 5c7253d21f
12 changed files with 693 additions and 0 deletions

View file

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

View file

@ -600,6 +600,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>

View file

@ -502,6 +502,13 @@ private fun ProgressControls(
onClick = onEpisodesClick,
)
}
if (onSubmitIntroClick != null) {
PlayerActionPillButton(
label = "Submit Intro",
icon = Icons.Rounded.Flag,
onClick = onSubmitIntroClick,
)
}
}
}
}

View file

@ -242,6 +242,7 @@ 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 episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
@ -1599,6 +1600,7 @@ fun PlayerScreen(
},
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
@ -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 },
)
}
}
}
}

View file

@ -178,6 +178,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 +386,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 +483,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = skipIntroEnabled,
animeSkipEnabled = animeSkipEnabled,
animeSkipClientId = animeSkipClientId,
introDbApiKey = introDbApiKey,
introSubmitEnabled = introSubmitEnabled,
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
nextEpisodeThresholdMode = nextEpisodeThresholdMode,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@OptIn(ExperimentalMaterial3Api::class)
private fun NextEpisodeThresholdModeDialog(

View file

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