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)