Merge branch 'cmp-rewrite' into feat/german

This commit is contained in:
Marius Butz 2026-05-03 23:09:47 +02:00
commit cf15e2008b
13 changed files with 753 additions and 2 deletions

View file

@ -45,6 +45,8 @@ actual object PlayerSettingsStorage {
private const val skipIntroEnabledKey = "skip_intro_enabled" private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled" private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id" 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 streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
@ -480,6 +482,33 @@ actual object PlayerSettingsStorage {
?.apply() ?.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? = actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
preferences?.let { sharedPreferences -> preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey) val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
@ -652,6 +681,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled)
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)

View file

@ -621,6 +621,10 @@
<string name="settings_playback_anime_skip">Anime Skip</string> <string name="settings_playback_anime_skip">Anime Skip</string>
<string name="settings_playback_anime_skip_client_id">AnimeSkip Client ID</string> <string name="settings_playback_anime_skip_client_id">AnimeSkip Client ID</string>
<string name="settings_playback_anime_skip_client_id_description">Enter your AnimeSkip API client ID. Get one at anime-skip.com.</string> <string name="settings_playback_anime_skip_client_id_description">Enter your AnimeSkip API client ID. Get one at anime-skip.com.</string>
<string name="settings_playback_intro_submit_enabled">Enable Intro Submission</string>
<string name="settings_playback_intro_submit_enabled_description">Show a button to submit intro/outro timestamps to the community database.</string>
<string name="settings_playback_introdb_api_key">IntroDB API Key</string>
<string name="settings_playback_introdb_api_key_description">Enter your IntroDB API key to submit timestamps. Required for submission.</string>
<string name="settings_playback_anime_skip_description">Also search AnimeSkip for skip timestamps (requires client ID).</string> <string name="settings_playback_anime_skip_description">Also search AnimeSkip for skip timestamps (requires client ID).</string>
<string name="settings_playback_auto_play_next_episode">Auto-Play Next Episode</string> <string name="settings_playback_auto_play_next_episode">Auto-Play Next Episode</string>
<string name="settings_playback_auto_play_next_episode_description">Automatically find and play the next episode when the threshold is reached.</string> <string name="settings_playback_auto_play_next_episode_description">Automatically find and play the next episode when the threshold is reached.</string>

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Forward10
import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.LockOpen
@ -79,6 +80,7 @@ internal fun PlayerControlsShell(
onAudioClick: () -> Unit, onAudioClick: () -> Unit,
onSourcesClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null,
onSubmitIntroClick: (() -> Unit)? = null,
onScrubChange: (Long) -> Unit, onScrubChange: (Long) -> Unit,
onScrubFinished: (Long) -> Unit, onScrubFinished: (Long) -> Unit,
horizontalSafePadding: androidx.compose.ui.unit.Dp, horizontalSafePadding: androidx.compose.ui.unit.Dp,
@ -166,6 +168,7 @@ internal fun PlayerControlsShell(
onAudioClick = onAudioClick, onAudioClick = onAudioClick,
onSourcesClick = onSourcesClick, onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick, onEpisodesClick = onEpisodesClick,
onSubmitIntroClick = onSubmitIntroClick,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
@ -421,6 +424,7 @@ private fun ProgressControls(
onAudioClick: () -> Unit, onAudioClick: () -> Unit,
onSourcesClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null,
onSubmitIntroClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
@ -502,6 +506,13 @@ private fun ProgressControls(
onClick = onEpisodesClick, onClick = onEpisodesClick,
) )
} }
if (onSubmitIntroClick != null) {
PlayerActionPillButton(
label = "Submit Intro",
icon = Icons.Rounded.Flag,
onClick = onSubmitIntroClick,
)
}
} }
} }
} }

View file

@ -241,6 +241,10 @@ fun PlayerScreen(
// Sources & Episodes Panel state // Sources & Episodes Panel state
var showSourcesPanel by remember { mutableStateOf(false) } var showSourcesPanel by remember { mutableStateOf(false) }
var showEpisodesPanel 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()) } var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) }
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle() val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle() val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
@ -1604,6 +1608,7 @@ fun PlayerScreen(
}, },
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs }, onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
onScrubFinished = { positionMs -> onScrubFinished = { positionMs ->
scrubbingPositionMs = null 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
}
)
}
} }
} }
} }

View file

@ -32,6 +32,8 @@ data class PlayerSettingsUiState(
val skipIntroEnabled: Boolean = true, val skipIntroEnabled: Boolean = true,
val animeSkipEnabled: Boolean = false, val animeSkipEnabled: Boolean = false,
val animeSkipClientId: String = "", val animeSkipClientId: String = "",
val introDbApiKey: String = "",
val introSubmitEnabled: Boolean = false,
val streamAutoPlayNextEpisodeEnabled: Boolean = false, val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroup: Boolean = true, val streamAutoPlayPreferBingeGroup: Boolean = true,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE, val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
@ -69,6 +71,8 @@ object PlayerSettingsRepository {
private var skipIntroEnabled = true private var skipIntroEnabled = true
private var animeSkipEnabled = false private var animeSkipEnabled = false
private var animeSkipClientId = "" private var animeSkipClientId = ""
private var introDbApiKey = ""
private var introSubmitEnabled = false
private var streamAutoPlayNextEpisodeEnabled = false private var streamAutoPlayNextEpisodeEnabled = false
private var streamAutoPlayPreferBingeGroup = true private var streamAutoPlayPreferBingeGroup = true
private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
@ -111,6 +115,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = true skipIntroEnabled = true
animeSkipEnabled = false animeSkipEnabled = false
animeSkipClientId = "" animeSkipClientId = ""
introDbApiKey = ""
introSubmitEnabled = false
streamAutoPlayNextEpisodeEnabled = false streamAutoPlayNextEpisodeEnabled = false
streamAutoPlayPreferBingeGroup = true streamAutoPlayPreferBingeGroup = true
nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
@ -178,6 +184,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true
animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false
animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: "" animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: ""
introDbApiKey = PlayerSettingsStorage.loadIntroDbApiKey() ?: ""
introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false
streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false
streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true
nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode() nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode()
@ -384,6 +392,22 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveAnimeSkipClientId(clientId) 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) { fun setStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
ensureLoaded() ensureLoaded()
if (streamAutoPlayNextEpisodeEnabled == enabled) return if (streamAutoPlayNextEpisodeEnabled == enabled) return
@ -465,6 +489,8 @@ object PlayerSettingsRepository {
skipIntroEnabled = skipIntroEnabled, skipIntroEnabled = skipIntroEnabled,
animeSkipEnabled = animeSkipEnabled, animeSkipEnabled = animeSkipEnabled,
animeSkipClientId = animeSkipClientId, animeSkipClientId = animeSkipClientId,
introDbApiKey = introDbApiKey,
introSubmitEnabled = introSubmitEnabled,
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled, streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup, streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
nextEpisodeThresholdMode = nextEpisodeThresholdMode, nextEpisodeThresholdMode = nextEpisodeThresholdMode,

View file

@ -55,6 +55,11 @@ internal expect object PlayerSettingsStorage {
fun saveAnimeSkipEnabled(enabled: Boolean) fun saveAnimeSkipEnabled(enabled: Boolean)
fun loadAnimeSkipClientId(): String? fun loadAnimeSkipClientId(): String?
fun saveAnimeSkipClientId(clientId: String) fun saveAnimeSkipClientId(clientId: String)
fun loadIntroDbApiKey(): String?
fun saveIntroDbApiKey(apiKey: String)
fun loadIntroSubmitEnabled(): Boolean?
fun saveIntroSubmitEnabled(enabled: Boolean)
fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean?
fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean)
fun loadStreamAutoPlayPreferBingeGroup(): Boolean? fun loadStreamAutoPlayPreferBingeGroup(): Boolean?

View file

@ -30,6 +30,62 @@ internal object SkipIntroApi {
} }
} }
suspend fun submitIntro(
apiKey: String,
request: SubmitIntroRequest,
): Boolean {
val baseUrl = IntroDbConfig.URL.trimEnd('/')
if (baseUrl.isBlank() || apiKey.isBlank()) return false
val url = "$baseUrl/submit"
val body = json.encodeToString(SubmitIntroRequest.serializer(), request)
val headers = mapOf(
"Authorization" to "Bearer $apiKey",
"Content-Type" to "application/json"
)
return try {
val response = com.nuvio.app.features.addons.httpRequestRaw(
method = "POST",
url = url,
headers = headers,
body = body
)
response.status == 200 || response.status == 201
} catch (_: Exception) {
false
}
}
suspend fun verifyIntroDbApiKey(apiKey: String): Boolean {
val baseUrl = IntroDbConfig.URL.trimEnd('/')
if (baseUrl.isBlank() || apiKey.isBlank()) return false
val url = "$baseUrl/submit"
val headers = mapOf(
"Authorization" to "Bearer $apiKey",
"Content-Type" to "application/json"
)
return try {
val response = com.nuvio.app.features.addons.httpRequestRaw(
method = "POST",
url = url,
headers = headers,
body = "{}"
)
// 400 means Auth passed but payload was empty/invalid -> Key is Valid
if (response.status == 400) return true
// 200/201 would also mean valid (though unexpected with empty body)
if (response.status == 200 || response.status == 201) return true
// Explicitly handle auth failures
if (response.status == 401 || response.status == 403) return false
false
} catch (_: Exception) {
false
}
}
// --- AniSkip --- // --- AniSkip ---
suspend fun getAniSkipTimes( suspend fun getAniSkipTimes(

View file

@ -241,6 +241,36 @@ object SkipIntroRepository {
} catch (_: Exception) { emptyList() }.also { imdbEntriesCache[imdbId] = it } } 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() { fun clearCache() {
cache.clear() cache.clear()
imdbEntriesCache.clear() imdbEntriesCache.clear()

View file

@ -50,6 +50,18 @@ data class IntroDbSegment(
@SerialName("updated_at") val updatedAt: String? = null, @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 --- // --- AniSkip API response models ---
@Serializable @Serializable

View file

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

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -36,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.features.streams.StreamAutoPlaySource
import com.nuvio.app.isIos import com.nuvio.app.isIos
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
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<String?>(null) }
BasicAlertDialog(onDismissRequest = { if (!isVerifying) onDismiss() }) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_playback_introdb_api_key),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.settings_playback_introdb_api_key_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (errorMessage != null) 1f else 0.3f)),
) {
BasicTextField(
value = value,
onValueChange = {
value = it
errorMessage = null
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
)
}
if (errorMessage != null) {
Text(
text = errorMessage!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 4.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss, enabled = !isVerifying) {
Text(stringResource(Res.string.action_cancel))
}
TextButton(
onClick = {
val trimmed = value.trim()
if (trimmed.isEmpty()) {
onSave(trimmed)
return@TextButton
}
if (trimmed == initialValue) {
onDismiss()
return@TextButton
}
isVerifying = true
errorMessage = null
scope.launch {
val isValid = com.nuvio.app.features.player.skip.SkipIntroRepository.verifyIntroDbApiKey(trimmed)
isVerifying = false
if (isValid) {
onSave(trimmed)
} else {
errorMessage = "Invalid API Key or connection failed"
}
}
},
enabled = !isVerifying
) {
if (isVerifying) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(stringResource(Res.string.action_save))
}
}
}
}
}
}
}
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun NextEpisodeThresholdModeDialog( private fun NextEpisodeThresholdModeDialog(
@ -2018,3 +2162,4 @@ private fun libassRenderTypeRes(renderType: String): StringResource = when (rend
@Composable @Composable
private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType)) private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType))

View file

@ -43,6 +43,8 @@ actual object PlayerSettingsStorage {
private const val skipIntroEnabledKey = "skip_intro_enabled" private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled" private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id" 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 streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled"
private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group"
private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode"
@ -418,6 +420,30 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = ProfileScopedKey.of(animeSkipClientIdKey)) 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? { actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey) val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
@ -559,6 +585,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled)
payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled)
payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId)
payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey)
payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled)
payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup)
payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode)