diff --git a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
index ef0ce528..e3705fb6 100644
Binary files a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar and b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar differ
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 ef139d3b..7cbb3f3e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -621,6 +621,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..48ffd528 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
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Forward10
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
@@ -79,6 +80,7 @@ internal fun PlayerControlsShell(
onAudioClick: () -> Unit,
onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null,
+ onSubmitIntroClick: (() -> Unit)? = null,
onScrubChange: (Long) -> Unit,
onScrubFinished: (Long) -> Unit,
horizontalSafePadding: androidx.compose.ui.unit.Dp,
@@ -166,6 +168,7 @@ internal fun PlayerControlsShell(
onAudioClick = onAudioClick,
onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick,
+ onSubmitIntroClick = onSubmitIntroClick,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
@@ -421,6 +424,7 @@ private fun ProgressControls(
onAudioClick: () -> Unit,
onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null,
+ onSubmitIntroClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
@@ -502,6 +506,13 @@ private fun ProgressControls(
onClick = onEpisodesClick,
)
}
+ if (onSubmitIntroClick != null) {
+ PlayerActionPillButton(
+ label = "Submit Intro",
+ icon = Icons.Rounded.Flag,
+ onClick = onSubmitIntroClick,
+ )
+ }
}
}
}
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 b9f9df07..a99d0be5 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
@@ -241,6 +241,10 @@ fun PlayerScreen(
// Sources & Episodes Panel state
var showSourcesPanel by remember { mutableStateOf(false) }
var showEpisodesPanel by remember { mutableStateOf(false) }
+ var showSubmitIntroModal by remember { mutableStateOf(false) }
+ var submitIntroSegmentType by rememberSaveable { mutableStateOf("intro") }
+ var submitIntroStartTimeStr by rememberSaveable { mutableStateOf("00:00") }
+ var submitIntroEndTimeStr by rememberSaveable { mutableStateOf("00:00") }
var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
@@ -1602,8 +1606,9 @@ fun PlayerScreen(
refreshTracks()
showAudioModal = true
},
- onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
- onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
+ onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
+ onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
+ onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
onScrubFinished = { positionMs ->
scrubbingPositionMs = null
@@ -1849,6 +1854,34 @@ fun PlayerScreen(
},
)
}
+
+ val season = activeSeasonNumber
+ val episode = activeEpisodeNumber
+ val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
+ ?: parentMetaId.takeIf { it.startsWith("tt") }
+ ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
+
+ if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) {
+ com.nuvio.app.features.player.skip.SubmitIntroDialog(
+ imdbId = imdbId,
+ season = season,
+ episode = episode,
+ currentTimeSec = (displayedPositionMs / 1000.0),
+ segmentType = submitIntroSegmentType,
+ onSegmentTypeChange = { submitIntroSegmentType = it },
+ startTimeStr = submitIntroStartTimeStr,
+ onStartTimeChange = { submitIntroStartTimeStr = it },
+ endTimeStr = submitIntroEndTimeStr,
+ onEndTimeChange = { submitIntroEndTimeStr = it },
+ onDismiss = { showSubmitIntroModal = false },
+ onSuccess = {
+ submitIntroStartTimeStr = "00:00"
+ submitIntroEndTimeStr = "00:00"
+ submitIntroSegmentType = "intro"
+ showSubmitIntroModal = false
+ }
+ )
+ }
}
}
}
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..ec58911e 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
@@ -32,6 +32,8 @@ data class PlayerSettingsUiState(
val skipIntroEnabled: Boolean = true,
val animeSkipEnabled: Boolean = false,
val animeSkipClientId: String = "",
+ val introDbApiKey: String = "",
+ val introSubmitEnabled: Boolean = false,
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroup: Boolean = true,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
@@ -69,6 +71,8 @@ object PlayerSettingsRepository {
private var skipIntroEnabled = true
private var animeSkipEnabled = false
private var animeSkipClientId = ""
+ private var introDbApiKey = ""
+ private var introSubmitEnabled = false
private var streamAutoPlayNextEpisodeEnabled = false
private var streamAutoPlayPreferBingeGroup = true
private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
@@ -111,6 +115,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = true
animeSkipEnabled = false
animeSkipClientId = ""
+ introDbApiKey = ""
+ introSubmitEnabled = false
streamAutoPlayNextEpisodeEnabled = false
streamAutoPlayPreferBingeGroup = true
nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
@@ -178,6 +184,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true
animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false
animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: ""
+ introDbApiKey = PlayerSettingsStorage.loadIntroDbApiKey() ?: ""
+ introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false
streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false
streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true
nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode()
@@ -384,6 +392,22 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveAnimeSkipClientId(clientId)
}
+ fun setIntroDbApiKey(apiKey: String) {
+ ensureLoaded()
+ if (introDbApiKey == apiKey) return
+ introDbApiKey = apiKey
+ publish()
+ PlayerSettingsStorage.saveIntroDbApiKey(apiKey)
+ }
+
+ fun setIntroSubmitEnabled(enabled: Boolean) {
+ ensureLoaded()
+ if (introSubmitEnabled == enabled) return
+ introSubmitEnabled = enabled
+ publish()
+ PlayerSettingsStorage.saveIntroSubmitEnabled(enabled)
+ }
+
fun setStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
ensureLoaded()
if (streamAutoPlayNextEpisodeEnabled == enabled) return
@@ -465,6 +489,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = skipIntroEnabled,
animeSkipEnabled = animeSkipEnabled,
animeSkipClientId = animeSkipClientId,
+ introDbApiKey = introDbApiKey,
+ introSubmitEnabled = introSubmitEnabled,
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
nextEpisodeThresholdMode = nextEpisodeThresholdMode,
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..7d3df719
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt
@@ -0,0 +1,371 @@
+package com.nuvio.app.features.player.skip
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.GpsFixed
+import androidx.compose.material.icons.rounded.PlayCircleOutline
+import androidx.compose.material.icons.rounded.Replay
+import androidx.compose.material.icons.rounded.Send
+import androidx.compose.material.icons.rounded.StopCircle
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import kotlin.math.floor
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SubmitIntroDialog(
+ imdbId: String,
+ season: Int,
+ episode: Int,
+ currentTimeSec: Double,
+ segmentType: String,
+ onSegmentTypeChange: (String) -> Unit,
+ startTimeStr: String,
+ onStartTimeChange: (String) -> Unit,
+ endTimeStr: String,
+ onEndTimeChange: (String) -> Unit,
+ onDismiss: () -> Unit,
+ onSuccess: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val scrollState = rememberScrollState()
+ var isSubmitting by remember { mutableStateOf(false) }
+
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ Surface(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 8.dp,
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .verticalScroll(scrollState),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "Submit Timestamps",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Bold,
+ )
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+
+ // Segment Type
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "SEGMENT TYPE",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SegmentTypeButton(
+ label = "Intro",
+ icon = Icons.Rounded.PlayCircleOutline,
+ selected = segmentType == "intro",
+ onClick = { onSegmentTypeChange("intro") },
+ modifier = Modifier.weight(1f)
+ )
+ SegmentTypeButton(
+ label = "Recap",
+ icon = Icons.Rounded.Replay,
+ selected = segmentType == "recap",
+ onClick = { onSegmentTypeChange("recap") },
+ modifier = Modifier.weight(1f)
+ )
+ SegmentTypeButton(
+ label = "Outro",
+ icon = Icons.Rounded.StopCircle,
+ selected = segmentType == "outro",
+ onClick = { onSegmentTypeChange("outro") },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ // Start Time
+ TimeInputRow(
+ label = "START TIME (MM:SS)",
+ value = startTimeStr,
+ onValueChange = onStartTimeChange,
+ onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) }
+ )
+
+ // End Time
+ TimeInputRow(
+ label = "END TIME (MM:SS)",
+ value = endTimeStr,
+ onValueChange = onEndTimeChange,
+ onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) }
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Actions
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(48.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(enabled = !isSubmitting, onClick = onDismiss),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Cancel",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ Box(
+ modifier = Modifier
+ .weight(2f)
+ .height(48.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.primary)
+ .clickable(enabled = !isSubmitting) {
+ val start = parseTimeToSeconds(startTimeStr)
+ val end = parseTimeToSeconds(endTimeStr)
+ if (start != null && end != null && end > start) {
+ isSubmitting = true
+ scope.launch {
+ val result = SkipIntroRepository.submitIntro(
+ imdbId = imdbId,
+ season = season,
+ episode = episode,
+ startSec = start,
+ endSec = end,
+ segmentType = segmentType,
+ )
+ isSubmitting = false
+ if (result) {
+ onSuccess()
+ }
+ }
+ }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ if (isSubmitting) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp))
+ Text(
+ text = "Submit",
+ color = MaterialTheme.colorScheme.onPrimary,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SegmentTypeButton(
+ label: String,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(onClick = onClick)
+ .padding(vertical = 10.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = label,
+ color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimeInputRow(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ onCapture: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ singleLine = true,
+ )
+ }
+ }
+ Box(
+ modifier = Modifier
+ .height(48.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(onClick = onCapture)
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Icon(
+ Icons.Rounded.GpsFixed,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = "Capture",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ }
+}
+
+private fun formatSecondsToMMSS(seconds: Double): String {
+ val mins = floor(seconds / 60).toInt()
+ val secs = floor(seconds % 60).toInt()
+ return "${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}"
+}
+
+private fun parseTimeToSeconds(input: String): Double? {
+ if (input.isBlank()) return null
+
+ // Check for separator (colon or dot)
+ val separator = when {
+ input.contains(':') -> ":"
+ input.contains('.') -> "."
+ else -> null
+ }
+
+ if (separator != null) {
+ val parts = input.split(separator)
+ if (parts.size == 2) {
+ val mins = parts[0].toIntOrNull() ?: return null
+ val secs = parts[1].toIntOrNull() ?: return null
+ // If the user uses a dot, we assume they mean MM.SS (e.g. 1.24 = 1m 24s)
+ // But we only treat it as minutes if seconds are 0-59.
+ if (secs in 0..59) {
+ return (mins * 60 + secs).toDouble()
+ }
+ }
+ }
+
+ return input.toDoubleOrNull()
+}
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..681383f3 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
@@ -23,6 +23,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -36,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -58,6 +60,7 @@ import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySource
import com.nuvio.app.isIos
+import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
@@ -472,6 +475,35 @@ private fun PlaybackSettingsSection(
)
}
}
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_intro_submit_enabled),
+ description = stringResource(Res.string.settings_playback_intro_submit_enabled_description),
+ checked = autoPlayPlayerSettings.introSubmitEnabled,
+ isTablet = isTablet,
+ onCheckedChange = PlayerSettingsRepository::setIntroSubmitEnabled,
+ )
+ if (autoPlayPlayerSettings.introSubmitEnabled) {
+ SettingsGroupDivider(isTablet = isTablet)
+ var showIntroDbApiKeyDialog by remember { mutableStateOf(false) }
+ val notSetLabel = stringResource(Res.string.settings_playback_not_set)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_introdb_api_key),
+ description = autoPlayPlayerSettings.introDbApiKey.ifBlank { notSetLabel },
+ isTablet = isTablet,
+ onClick = { showIntroDbApiKeyDialog = true },
+ )
+ if (showIntroDbApiKeyDialog) {
+ IntroDbApiKeyDialog(
+ initialValue = autoPlayPlayerSettings.introDbApiKey,
+ onSave = {
+ PlayerSettingsRepository.setIntroDbApiKey(it)
+ showIntroDbApiKeyDialog = false
+ },
+ onDismiss = { showIntroDbApiKeyDialog = false },
+ )
+ }
+ }
}
}
@@ -1895,6 +1927,118 @@ private fun AnimeSkipClientIdDialog(
}
}
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun IntroDbApiKeyDialog(
+ initialValue: String,
+ onSave: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var value by remember { mutableStateOf(initialValue) }
+ var isVerifying by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ BasicAlertDialog(onDismissRequest = { if (!isVerifying) onDismiss() }) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.settings_playback_introdb_api_key),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(Res.string.settings_playback_introdb_api_key_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (errorMessage != null) 1f else 0.3f)),
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = {
+ value = it
+ errorMessage = null
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ singleLine = true,
+ )
+ }
+ if (errorMessage != null) {
+ Text(
+ text = errorMessage!!,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(start = 4.dp)
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onDismiss, enabled = !isVerifying) {
+ Text(stringResource(Res.string.action_cancel))
+ }
+ TextButton(
+ onClick = {
+ val trimmed = value.trim()
+ if (trimmed.isEmpty()) {
+ onSave(trimmed)
+ return@TextButton
+ }
+
+ if (trimmed == initialValue) {
+ onDismiss()
+ return@TextButton
+ }
+
+ isVerifying = true
+ errorMessage = null
+ scope.launch {
+ val isValid = com.nuvio.app.features.player.skip.SkipIntroRepository.verifyIntroDbApiKey(trimmed)
+ isVerifying = false
+ if (isValid) {
+ onSave(trimmed)
+ } else {
+ errorMessage = "Invalid API Key or connection failed"
+ }
+ }
+ },
+ enabled = !isVerifying
+ ) {
+ if (isVerifying) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ Text(stringResource(Res.string.action_save))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun NextEpisodeThresholdModeDialog(
@@ -2018,3 +2162,4 @@ private fun libassRenderTypeRes(renderType: String): StringResource = when (rend
@Composable
private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType))
+
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)