mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
feat: implement IntroDB submission feature
This commit is contained in:
parent
8a58fabfdd
commit
5c7253d21f
12 changed files with 693 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -600,6 +600,10 @@
|
|||
<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_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_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>
|
||||
|
|
|
|||
|
|
@ -502,6 +502,13 @@ private fun ProgressControls(
|
|||
onClick = onEpisodesClick,
|
||||
)
|
||||
}
|
||||
if (onSubmitIntroClick != null) {
|
||||
PlayerActionPillButton(
|
||||
label = "Submit Intro",
|
||||
icon = Icons.Rounded.Flag,
|
||||
onClick = onSubmitIntroClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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<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
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun NextEpisodeThresholdModeDialog(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue