From 5c7253d21fd3dc2ddbc8680b196905f75a5afd15 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Sat, 2 May 2026 11:27:47 +0530 Subject: [PATCH] feat: implement IntroDB submission feature --- .../player/PlayerSettingsStorage.android.kt | 31 ++ .../composeResources/values/strings.xml | 4 + .../app/features/player/PlayerControls.kt | 7 + .../nuvio/app/features/player/PlayerScreen.kt | 12 + .../player/PlayerSettingsRepository.kt | 20 + .../features/player/PlayerSettingsStorage.kt | 5 + .../app/features/player/skip/SkipIntroApi.kt | 56 +++ .../player/skip/SkipIntroRepository.kt | 30 ++ .../app/features/player/skip/SkipModels.kt | 12 + .../features/player/skip/SubmitIntroDialog.kt | 348 ++++++++++++++++++ .../features/settings/PlaybackSettingsPage.kt | 141 +++++++ .../player/PlayerSettingsStorage.ios.kt | 27 ++ 12 files changed, 693 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt 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)