mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +00:00
Merge branch 'cmp-rewrite' into feat/german
This commit is contained in:
commit
cf15e2008b
13 changed files with 753 additions and 2 deletions
Binary file not shown.
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -1602,8 +1606,9 @@ fun PlayerScreen(
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
showAudioModal = true
|
showAudioModal = true
|
||||||
},
|
},
|
||||||
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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue