diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index ddcc8a9f..4a589306 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -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)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 4a014ff0..1d7de0e3 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -600,6 +600,10 @@
Anime Skip
AnimeSkip Client ID
Enter your AnimeSkip API client ID. Get one at anime-skip.com.
+ Enable Intro Submission
+ Show a button to submit intro/outro timestamps to the community database.
+ IntroDB API Key
+ Enter your IntroDB API key to submit timestamps. Required for submission.
Also search AnimeSkip for skip timestamps (requires client ID).
Auto-Play Next Episode
Automatically find and play the next episode when the threshold is reached.
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt
index 018c1d5e..0a430e27 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt
@@ -502,6 +502,13 @@ private fun ProgressControls(
onClick = onEpisodesClick,
)
}
+ if (onSubmitIntroClick != null) {
+ PlayerActionPillButton(
+ label = "Submit Intro",
+ icon = Icons.Rounded.Flag,
+ onClick = onSubmitIntroClick,
+ )
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index c3c5dd75..a68ff0bd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -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 },
+ )
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index df32f47d..eee56907 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
index 929d7deb..efc6b6c2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
@@ -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?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt
index 87b7edfe..25438fde 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt
@@ -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(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt
index 4e8ebf68..9e96d4aa 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt
index c97d27f8..0e996a97 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt
new file mode 100644
index 00000000..0771633b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt
@@ -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()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
index d3ba2d92..24c786df 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt
@@ -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(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(
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
index c4e9795e..3f63f5db 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
@@ -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)