feat: adding skip intro/next episode logic

This commit is contained in:
tapframe 2026-04-04 20:34:14 +05:30
parent 5ecb5b8131
commit 49a178c7f9
15 changed files with 1935 additions and 0 deletions

View file

@ -54,6 +54,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/features/player/skip").apply {
mkdirs()
resolve("IntroDbConfig.kt").writeText(
"""
|package com.nuvio.app.features.player.skip
|
|object IntroDbConfig {
| const val URL = "${props.getProperty("INTRODB_API_URL", "")}"
|}
""".trimMargin()
)
}
}
}

View file

@ -26,6 +26,14 @@ actual object PlayerSettingsStorage {
private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins"
private const val streamAutoPlayRegexKey = "stream_auto_play_regex"
private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds"
private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id"
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"
private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2"
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
private var preferences: SharedPreferences? = null
@ -330,4 +338,126 @@ actual object PlayerSettingsStorage {
?.putInt(ProfileScopedKey.of(streamAutoPlayTimeoutSecondsKey), seconds)
?.apply()
}
actual fun loadSkipIntroEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(skipIntroEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, true)
} else {
null
}
}
actual fun saveSkipIntroEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(skipIntroEnabledKey), enabled)
?.apply()
}
actual fun loadAnimeSkipEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(animeSkipEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, false)
} else {
null
}
}
actual fun saveAnimeSkipEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(animeSkipEnabledKey), enabled)
?.apply()
}
actual fun loadAnimeSkipClientId(): String? =
preferences?.getString(ProfileScopedKey.of(animeSkipClientIdKey), null)
actual fun saveAnimeSkipClientId(clientId: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(animeSkipClientIdKey), clientId)
?.apply()
}
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, false)
} else {
null
}
}
actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey), enabled)
?.apply()
}
actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, true)
} else {
null
}
}
actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey), enabled)
?.apply()
}
actual fun loadNextEpisodeThresholdMode(): String? =
preferences?.getString(ProfileScopedKey.of(nextEpisodeThresholdModeKey), null)
actual fun saveNextEpisodeThresholdMode(mode: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(nextEpisodeThresholdModeKey), mode)
?.apply()
}
actual fun loadNextEpisodeThresholdPercent(): Float? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(nextEpisodeThresholdPercentKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getFloat(key, 99f)
} else {
null
}
}
actual fun saveNextEpisodeThresholdPercent(percent: Float) {
preferences
?.edit()
?.putFloat(ProfileScopedKey.of(nextEpisodeThresholdPercentKey), percent)
?.apply()
}
actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(nextEpisodeThresholdMinutesBeforeEndKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getFloat(key, 2f)
} else {
null
}
}
actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) {
preferences
?.edit()
?.putFloat(ProfileScopedKey.of(nextEpisodeThresholdMinutesBeforeEndKey), minutes)
?.apply()
}
}

View file

@ -0,0 +1,12 @@
package com.nuvio.app.features.player.skip
import java.util.Calendar
internal actual fun currentDateComponents(): DateComponents {
val cal = Calendar.getInstance()
return DateComponents(
year = cal.get(Calendar.YEAR),
month = cal.get(Calendar.MONTH) + 1,
day = cal.get(Calendar.DAY_OF_MONTH),
)
}

View file

@ -33,8 +33,17 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.player.skip.NextEpisodeCard
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
import com.nuvio.app.features.player.skip.SkipIntroButton
import com.nuvio.app.features.player.skip.SkipIntroRepository
import com.nuvio.app.features.player.skip.SkipInterval
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState
@ -45,6 +54,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@ -53,6 +63,16 @@ private const val PlaybackProgressPersistIntervalMs = 60_000L
private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f
private val PlayerSliderOverlayGap = 12.dp
private val PlayerTimeRowHeight = 36.dp
private val PlayerActionRowHeight = 50.dp
private fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) =
metrics.sliderBottomOffset +
metrics.sliderTouchHeight +
PlayerTimeRowHeight +
PlayerActionRowHeight +
PlayerSliderOverlayGap
private enum class PlayerSideGesture {
Brightness,
@ -100,6 +120,8 @@ fun PlayerScreen(
) {
val horizontalSafePadding = playerHorizontalSafePadding()
val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) }
val sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding
val overlayBottomPadding = sliderOverlayBottomPadding(metrics)
val scope = rememberCoroutineScope()
val gestureController = rememberPlayerGestureController()
var controlsVisible by rememberSaveable { mutableStateOf(true) }
@ -167,6 +189,20 @@ fun PlayerScreen(
metaUiState.meta?.videos ?: emptyList()
}
val isSeries = parentMetaType == "series"
// Skip intro/outro/recap state
var skipIntervals by remember { mutableStateOf<List<SkipInterval>>(emptyList()) }
var activeSkipInterval by remember { mutableStateOf<SkipInterval?>(null) }
var skipIntervalDismissed by remember { mutableStateOf(false) }
// Next episode state
var nextEpisodeInfo by remember { mutableStateOf<NextEpisodeInfo?>(null) }
var showNextEpisodeCard by remember { mutableStateOf(false) }
var nextEpisodeAutoPlaySearching by remember { mutableStateOf(false) }
var nextEpisodeAutoPlaySourceName by remember { mutableStateOf<String?>(null) }
var nextEpisodeAutoPlayCountdown by remember { mutableStateOf<Int?>(null) }
var nextEpisodeAutoPlayJob by remember { mutableStateOf<Job?>(null) }
val playbackSession = remember(
contentType,
parentMetaId,
@ -523,6 +559,95 @@ fun PlayerScreen(
controlsVisible = true
}
fun playNextEpisode() {
val nextVideo = allEpisodes.firstOrNull { video ->
video.season == nextEpisodeInfo?.season && video.episode == nextEpisodeInfo?.episode
} ?: return
if (nextEpisodeInfo?.hasAired != true) return
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlaySearching = true
nextEpisodeAutoPlaySourceName = null
nextEpisodeAutoPlayCountdown = null
val type = contentType ?: parentMetaType
val settings = playerSettingsUiState
// Determine auto-play mode for next episode
val effectiveMode = if (settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL) {
if (settings.streamAutoPlayNextEpisodeEnabled) StreamAutoPlayMode.FIRST_STREAM
else StreamAutoPlayMode.MANUAL
} else {
settings.streamAutoPlayMode
}
val currentBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) {
// Try to find binge group of current stream (not directly available, pass empty)
""
} else ""
nextEpisodeAutoPlayJob = scope.launch {
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
videoId = nextVideo.id,
season = nextVideo.season,
episode = nextVideo.episode,
)
val installedAddonNames = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest?.name }
.toSet()
val timeoutMs = settings.streamAutoPlayTimeoutSeconds * 1000L
val startTime = WatchProgressClock.nowEpochMs()
// Collect streams as they arrive
PlayerStreamsRepository.episodeStreamsState.collectLatest { state ->
if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest
val allStreams = state.groups.flatMap { it.streams }
val elapsed = WatchProgressClock.nowEpochMs() - startTime
val selected = if (allStreams.isNotEmpty()) {
StreamAutoPlaySelector.selectAutoPlayStream(
streams = allStreams,
mode = effectiveMode,
regexPattern = settings.streamAutoPlayRegex,
source = settings.streamAutoPlaySource,
installedAddonNames = installedAddonNames,
selectedAddons = settings.streamAutoPlaySelectedAddons,
selectedPlugins = settings.streamAutoPlaySelectedPlugins,
)
} else null
if (selected != null || !state.isAnyLoading || elapsed >= timeoutMs) {
nextEpisodeAutoPlaySearching = false
if (selected != null) {
nextEpisodeAutoPlaySourceName = selected.addonName
// Countdown before playing
for (i in 3 downTo 1) {
nextEpisodeAutoPlayCountdown = i
delay(1000)
}
switchToEpisodeStream(selected, nextVideo)
showNextEpisodeCard = false
nextEpisodeAutoPlayCountdown = null
nextEpisodeAutoPlaySourceName = null
} else if (!state.isAnyLoading || elapsed >= timeoutMs) {
// No stream found — open the episode streams panel for manual selection
episodeStreamsPanelState = EpisodeStreamsPanelState(
showStreams = true,
selectedEpisode = nextVideo,
)
showEpisodesPanel = true
showNextEpisodeCard = false
}
return@collectLatest
}
}
}
}
fun openSourcesPanel() {
val type = contentType ?: parentMetaType
val vid = activeVideoId ?: return
@ -674,6 +799,121 @@ fun PlayerScreen(
)
}
// Fetch skip intervals when episode changes
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
skipIntervals = emptyList()
activeSkipInterval = null
skipIntervalDismissed = false
showNextEpisodeCard = false
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlaySearching = false
val season = activeSeasonNumber
val episode = activeEpisodeNumber
val vid = activeVideoId
if (season == null || episode == null || vid == null) return@LaunchedEffect
launch {
val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") }
val intervals = SkipIntroRepository.getSkipIntervals(
imdbId = imdbId,
season = season,
episode = episode,
)
skipIntervals = intervals
}
}
// Update active skip interval based on playback position
LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) {
if (skipIntervals.isEmpty()) {
activeSkipInterval = null
return@LaunchedEffect
}
val positionSec = playbackSnapshot.positionMs / 1000.0
val current = skipIntervals.firstOrNull { interval ->
positionSec >= interval.startTime && positionSec < interval.endTime
}
if (current != activeSkipInterval) {
activeSkipInterval = current
if (current != null) skipIntervalDismissed = false
}
}
// Resolve next episode info when episodes list or current episode changes
LaunchedEffect(allEpisodes, activeSeasonNumber, activeEpisodeNumber) {
if (!isSeries || allEpisodes.isEmpty()) {
nextEpisodeInfo = null
return@LaunchedEffect
}
val curSeason = activeSeasonNumber ?: return@LaunchedEffect
val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect
val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode(
videos = allEpisodes,
currentSeason = curSeason,
currentEpisode = curEpisode,
)
nextEpisodeInfo = if (nextVideo != null && nextVideo.season != null && nextVideo.episode != null) {
NextEpisodeInfo(
videoId = nextVideo.id,
season = nextVideo.season!!,
episode = nextVideo.episode!!,
title = nextVideo.title,
thumbnail = nextVideo.thumbnail,
overview = nextVideo.overview,
released = nextVideo.released,
hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
"Airs ${nextVideo.released ?: "TBA"}"
} else null,
)
} else null
}
// Show next episode card at threshold
LaunchedEffect(
playbackSnapshot.positionMs,
playbackSnapshot.durationMs,
nextEpisodeInfo,
skipIntervals,
playerSettingsUiState.nextEpisodeThresholdMode,
playerSettingsUiState.nextEpisodeThresholdPercent,
playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
) {
if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) {
showNextEpisodeCard = false
return@LaunchedEffect
}
val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard(
positionMs = playbackSnapshot.positionMs,
durationMs = playbackSnapshot.durationMs,
skipIntervals = skipIntervals,
thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode,
thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent,
thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
)
if (shouldShow && !showNextEpisodeCard) {
showNextEpisodeCard = true
// Auto-play if enabled
if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
playNextEpisode()
}
} else if (!shouldShow) {
showNextEpisodeCard = false
}
}
// Auto-play on video ended if next episode card isn't already showing
LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) {
if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) {
showNextEpisodeCard = true
if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
playNextEpisode()
}
}
}
DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
onDispose {
flushWatchProgress()
@ -900,6 +1140,47 @@ fun PlayerScreen(
}
}
// Skip intro/recap/outro button
SkipIntroButton(
interval = activeSkipInterval,
dismissed = skipIntervalDismissed,
controlsVisible = controlsVisible,
onSkip = {
val interval = activeSkipInterval ?: return@SkipIntroButton
playerController?.seekTo((interval.endTime * 1000).toLong())
skipIntervalDismissed = true
},
onDismiss = { skipIntervalDismissed = true },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
)
// Next episode card
if (isSeries) {
NextEpisodeCard(
nextEpisode = nextEpisodeInfo,
visible = showNextEpisodeCard,
isAutoPlaySearching = nextEpisodeAutoPlaySearching,
autoPlaySourceName = nextEpisodeAutoPlaySourceName,
autoPlayCountdownSec = nextEpisodeAutoPlayCountdown,
onPlayNext = {
nextEpisodeAutoPlayJob?.cancel()
playNextEpisode()
},
onDismiss = {
nextEpisodeAutoPlayJob?.cancel()
showNextEpisodeCard = false
nextEpisodeAutoPlaySearching = false
nextEpisodeAutoPlaySourceName = null
nextEpisodeAutoPlayCountdown = null
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = sliderEdgePadding, bottom = overlayBottomPadding),
)
}
if (errorMessage != null) {
ErrorModal(
message = errorMessage.orEmpty(),

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.player.skip.NextEpisodeThresholdMode
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySource
import kotlinx.coroutines.flow.MutableStateFlow
@ -24,6 +25,14 @@ data class PlayerSettingsUiState(
val streamAutoPlaySelectedPlugins: Set<String> = emptySet(),
val streamAutoPlayRegex: String = "",
val streamAutoPlayTimeoutSeconds: Int = 3,
val skipIntroEnabled: Boolean = true,
val animeSkipEnabled: Boolean = false,
val animeSkipClientId: String = "",
val streamAutoPlayNextEpisodeEnabled: Boolean = false,
val streamAutoPlayPreferBingeGroup: Boolean = true,
val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE,
val nextEpisodeThresholdPercent: Float = 99f,
val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f,
)
object PlayerSettingsRepository {
@ -48,6 +57,14 @@ object PlayerSettingsRepository {
private var streamAutoPlaySelectedPlugins: Set<String> = emptySet()
private var streamAutoPlayRegex = ""
private var streamAutoPlayTimeoutSeconds = 3
private var skipIntroEnabled = true
private var animeSkipEnabled = false
private var animeSkipClientId = ""
private var streamAutoPlayNextEpisodeEnabled = false
private var streamAutoPlayPreferBingeGroup = true
private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
private var nextEpisodeThresholdPercent = 99f
private var nextEpisodeThresholdMinutesBeforeEnd = 2f
fun ensureLoaded() {
if (hasLoaded) return
@ -77,6 +94,14 @@ object PlayerSettingsRepository {
streamAutoPlaySelectedPlugins = emptySet()
streamAutoPlayRegex = ""
streamAutoPlayTimeoutSeconds = 3
skipIntroEnabled = true
animeSkipEnabled = false
animeSkipClientId = ""
streamAutoPlayNextEpisodeEnabled = false
streamAutoPlayPreferBingeGroup = true
nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE
nextEpisodeThresholdPercent = 99f
nextEpisodeThresholdMinutesBeforeEnd = 2f
publish()
}
@ -118,6 +143,16 @@ object PlayerSettingsRepository {
streamAutoPlaySelectedPlugins = PlayerSettingsStorage.loadStreamAutoPlaySelectedPlugins() ?: emptySet()
streamAutoPlayRegex = PlayerSettingsStorage.loadStreamAutoPlayRegex() ?: ""
streamAutoPlayTimeoutSeconds = PlayerSettingsStorage.loadStreamAutoPlayTimeoutSeconds() ?: 3
skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true
animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false
animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: ""
streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false
streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true
nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode()
?.let { runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrNull() }
?: NextEpisodeThresholdMode.PERCENTAGE
nextEpisodeThresholdPercent = PlayerSettingsStorage.loadNextEpisodeThresholdPercent() ?: 99f
nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f
publish()
}
@ -264,6 +299,70 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(seconds)
}
fun setSkipIntroEnabled(enabled: Boolean) {
ensureLoaded()
if (skipIntroEnabled == enabled) return
skipIntroEnabled = enabled
publish()
PlayerSettingsStorage.saveSkipIntroEnabled(enabled)
}
fun setAnimeSkipEnabled(enabled: Boolean) {
ensureLoaded()
if (animeSkipEnabled == enabled) return
animeSkipEnabled = enabled
publish()
PlayerSettingsStorage.saveAnimeSkipEnabled(enabled)
}
fun setAnimeSkipClientId(clientId: String) {
ensureLoaded()
if (animeSkipClientId == clientId) return
animeSkipClientId = clientId
publish()
PlayerSettingsStorage.saveAnimeSkipClientId(clientId)
}
fun setStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
ensureLoaded()
if (streamAutoPlayNextEpisodeEnabled == enabled) return
streamAutoPlayNextEpisodeEnabled = enabled
publish()
PlayerSettingsStorage.saveStreamAutoPlayNextEpisodeEnabled(enabled)
}
fun setStreamAutoPlayPreferBingeGroup(enabled: Boolean) {
ensureLoaded()
if (streamAutoPlayPreferBingeGroup == enabled) return
streamAutoPlayPreferBingeGroup = enabled
publish()
PlayerSettingsStorage.saveStreamAutoPlayPreferBingeGroup(enabled)
}
fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) {
ensureLoaded()
if (nextEpisodeThresholdMode == mode) return
nextEpisodeThresholdMode = mode
publish()
PlayerSettingsStorage.saveNextEpisodeThresholdMode(mode.name)
}
fun setNextEpisodeThresholdPercent(percent: Float) {
ensureLoaded()
if (nextEpisodeThresholdPercent == percent) return
nextEpisodeThresholdPercent = percent
publish()
PlayerSettingsStorage.saveNextEpisodeThresholdPercent(percent)
}
fun setNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) {
ensureLoaded()
if (nextEpisodeThresholdMinutesBeforeEnd == minutes) return
nextEpisodeThresholdMinutesBeforeEnd = minutes
publish()
PlayerSettingsStorage.saveNextEpisodeThresholdMinutesBeforeEnd(minutes)
}
private fun publish() {
_uiState.value = PlayerSettingsUiState(
showLoadingOverlay = showLoadingOverlay,
@ -283,6 +382,14 @@ object PlayerSettingsRepository {
streamAutoPlaySelectedPlugins = streamAutoPlaySelectedPlugins,
streamAutoPlayRegex = streamAutoPlayRegex,
streamAutoPlayTimeoutSeconds = streamAutoPlayTimeoutSeconds,
skipIntroEnabled = skipIntroEnabled,
animeSkipEnabled = animeSkipEnabled,
animeSkipClientId = animeSkipClientId,
streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled,
streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup,
nextEpisodeThresholdMode = nextEpisodeThresholdMode,
nextEpisodeThresholdPercent = nextEpisodeThresholdPercent,
nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd,
)
}
}

View file

@ -41,4 +41,20 @@ internal expect object PlayerSettingsStorage {
fun saveStreamAutoPlayRegex(regex: String)
fun loadStreamAutoPlayTimeoutSeconds(): Int?
fun saveStreamAutoPlayTimeoutSeconds(seconds: Int)
fun loadSkipIntroEnabled(): Boolean?
fun saveSkipIntroEnabled(enabled: Boolean)
fun loadAnimeSkipEnabled(): Boolean?
fun saveAnimeSkipEnabled(enabled: Boolean)
fun loadAnimeSkipClientId(): String?
fun saveAnimeSkipClientId(clientId: String)
fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean?
fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean)
fun loadStreamAutoPlayPreferBingeGroup(): Boolean?
fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean)
fun loadNextEpisodeThresholdMode(): String?
fun saveNextEpisodeThresholdMode(mode: String)
fun loadNextEpisodeThresholdPercent(): Float?
fun saveNextEpisodeThresholdPercent(percent: Float)
fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float?
fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float)
}

View file

@ -0,0 +1,167 @@
package com.nuvio.app.features.player.skip
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@Composable
fun NextEpisodeCard(
nextEpisode: NextEpisodeInfo?,
visible: Boolean,
isAutoPlaySearching: Boolean,
autoPlaySourceName: String?,
autoPlayCountdownSec: Int?,
onPlayNext: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
if (nextEpisode == null) return
val isPlayable = nextEpisode.hasAired
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(animationSpec = tween(260), initialOffsetX = { it / 2 }) +
fadeIn(animationSpec = tween(220)),
exit = slideOutHorizontally(animationSpec = tween(200), targetOffsetX = { it / 2 }) +
fadeOut(animationSpec = tween(160)),
modifier = modifier,
) {
val shape = RoundedCornerShape(16.dp)
Row(
modifier = Modifier
.widthIn(max = 292.dp)
.clip(shape)
.background(Color(0xFF191919).copy(alpha = 0.89f))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
.clickable { if (isPlayable) onPlayNext() }
.padding(horizontal = 9.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Thumbnail
Box(
modifier = Modifier
.size(width = 78.dp, height = 44.dp)
.clip(RoundedCornerShape(9.dp)),
) {
AsyncImage(
model = nextEpisode.thumbnail,
contentDescription = "Next episode thumbnail",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.32f),
),
),
),
)
}
Spacer(modifier = Modifier.width(8.dp))
// Info
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Next Episode",
color = Color.White.copy(alpha = 0.8f),
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "S${nextEpisode.season}E${nextEpisode.episode}${nextEpisode.title}",
color = Color.White,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold,
)
val autoPlayStatus = when {
!isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage
isAutoPlaySearching -> "Finding source…"
!autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null ->
"Playing via $autoPlaySourceName in $autoPlayCountdownSec"
else -> null
}
if (autoPlayStatus != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = autoPlayStatus,
color = Color.White.copy(alpha = 0.78f),
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Play badge
Row(
modifier = Modifier
.padding(start = 4.dp)
.clip(CircleShape)
.border(1.dp, Color.White.copy(alpha = 0.2f), CircleShape)
.padding(horizontal = 8.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = if (isPlayable) Color.White else Color.White.copy(alpha = 0.65f),
modifier = Modifier.size(13.dp),
)
Text(
text = if (isPlayable) "Play" else "Unaired",
color = if (isPlayable) Color.White else Color.White.copy(alpha = 0.72f),
fontSize = 11.sp,
modifier = Modifier.padding(start = 3.dp),
)
}
}
}
}

View file

@ -0,0 +1,83 @@
package com.nuvio.app.features.player.skip
import com.nuvio.app.features.details.MetaVideo
object PlayerNextEpisodeRules {
fun resolveNextEpisode(
videos: List<MetaVideo>,
currentSeason: Int?,
currentEpisode: Int?,
): MetaVideo? {
if (currentSeason == null || currentEpisode == null) return null
val sortedEpisodes = videos
.filter { it.season != null && it.episode != null }
.sortedWith(
compareBy<MetaVideo> { it.season ?: Int.MAX_VALUE }
.thenBy { it.episode ?: Int.MAX_VALUE }
)
val currentIndex = sortedEpisodes.indexOfFirst {
it.season == currentSeason && it.episode == currentEpisode
}
if (currentIndex < 0) return null
return sortedEpisodes.getOrNull(currentIndex + 1)
}
fun shouldShowNextEpisodeCard(
positionMs: Long,
durationMs: Long,
skipIntervals: List<SkipInterval>,
thresholdMode: NextEpisodeThresholdMode,
thresholdPercent: Float,
thresholdMinutesBeforeEnd: Float,
): Boolean {
val outroInterval = skipIntervals.firstOrNull { it.type == "outro" }
return if (outroInterval != null) {
positionMs / 1000.0 >= outroInterval.startTime
} else {
if (durationMs <= 0L) return false
when (thresholdMode) {
NextEpisodeThresholdMode.PERCENTAGE -> {
val clampedPercent = thresholdPercent.coerceIn(97f, 99.5f)
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
}
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(1f, 3.5f)
val remainingMs = durationMs - positionMs
remainingMs <= (clampedMinutes * 60_000f).toLong()
}
}
}
}
fun hasEpisodeAired(raw: String?): Boolean {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return true
val dateStr = when {
value.length >= 10 -> value.substring(0, 10)
else -> return true
}
// Parse YYYY-MM-DD
val parts = dateStr.split("-")
if (parts.size != 3) return true
val year = parts[0].toIntOrNull() ?: return true
val month = parts[1].toIntOrNull() ?: return true
val day = parts[2].toIntOrNull() ?: return true
val today = currentDateComponents()
return compareDate(year, month, day, today.year, today.month, today.day) <= 0
}
private fun compareDate(
y1: Int, m1: Int, d1: Int,
y2: Int, m2: Int, d2: Int,
): Int {
if (y1 != y2) return y1.compareTo(y2)
if (m1 != m2) return m1.compareTo(m2)
return d1.compareTo(d2)
}
}
internal expect fun currentDateComponents(): DateComponents
data class DateComponents(val year: Int, val month: Int, val day: Int)

View file

@ -0,0 +1,131 @@
package com.nuvio.app.features.player.skip
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import kotlinx.serialization.json.Json
internal object SkipIntroApi {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private const val ANISKIP_BASE = "https://api.aniskip.com/v2/"
private const val ARM_BASE = "https://arm.haglund.dev/api/v2/"
private const val ANIMESKIP_BASE = "https://api.anime-skip.com/"
// --- IntroDb ---
suspend fun getIntroDbSegments(
imdbId: String,
season: Int,
episode: Int,
): IntroDbSegmentsResponse? {
val baseUrl = IntroDbConfig.URL.trimEnd('/')
if (baseUrl.isBlank()) return null
val url = "$baseUrl/segments?imdb_id=$imdbId&season=$season&episode=$episode"
return try {
val text = httpGetText(url)
json.decodeFromString<IntroDbSegmentsResponse>(text)
} catch (_: Exception) {
null
}
}
// --- AniSkip ---
suspend fun getAniSkipTimes(
malId: String,
episode: Int,
): AniSkipResponse? {
val types = "op,ed,recap,mixed-op,mixed-ed"
val url = "${ANISKIP_BASE}skip-times/$malId/$episode?types=$types&episodeLength=0"
return try {
val text = httpGetText(url)
json.decodeFromString<AniSkipResponse>(text)
} catch (_: Exception) {
null
}
}
// --- ARM API (ID resolution) ---
suspend fun resolveImdbToAll(imdbId: String): List<ArmEntry> {
val url = "${ARM_BASE}imdb?id=$imdbId&include=myanimelist,anilist,kitsu"
return try {
val text = httpGetText(url)
json.decodeFromString<List<ArmEntry>>(text)
} catch (_: Exception) {
emptyList()
}
}
suspend fun resolveMalToImdb(malId: String): ArmEntry? {
val url = "${ARM_BASE}ids?source=myanimelist&id=$malId&include=imdb"
return try {
val text = httpGetText(url)
json.decodeFromString<ArmEntry>(text)
} catch (_: Exception) {
null
}
}
suspend fun resolveMalToAnilist(malId: String): ArmEntry? {
val url = "${ARM_BASE}ids?source=myanimelist&id=$malId&include=anilist"
return try {
val text = httpGetText(url)
json.decodeFromString<ArmEntry>(text)
} catch (_: Exception) {
null
}
}
suspend fun resolveKitsuToMal(kitsuId: String): ArmEntry? {
val url = "${ARM_BASE}ids?source=kitsu&id=$kitsuId&include=myanimelist"
return try {
val text = httpGetText(url)
json.decodeFromString<ArmEntry>(text)
} catch (_: Exception) {
null
}
}
suspend fun resolveKitsuToAnilist(kitsuId: String): ArmEntry? {
val url = "${ARM_BASE}ids?source=kitsu&id=$kitsuId&include=anilist"
return try {
val text = httpGetText(url)
json.decodeFromString<ArmEntry>(text)
} catch (_: Exception) {
null
}
}
suspend fun resolveKitsuToImdb(kitsuId: String): ArmEntry? {
val url = "${ARM_BASE}ids?source=kitsu&id=$kitsuId&include=imdb"
return try {
val text = httpGetText(url)
json.decodeFromString<ArmEntry>(text)
} catch (_: Exception) {
null
}
}
// --- Anime-Skip GraphQL ---
suspend fun queryAnimeSkip(clientId: String, graphqlQuery: String): AnimeSkipGraphqlResponse? {
val body = json.encodeToString(
kotlinx.serialization.json.JsonObject.serializer(),
kotlinx.serialization.json.buildJsonObject {
put("query", kotlinx.serialization.json.JsonPrimitive(graphqlQuery))
}
)
val headers = mapOf(
"X-Client-ID" to clientId,
"Content-Type" to "application/json",
)
return try {
val text = httpPostJsonWithHeaders(ANIMESKIP_BASE + "graphql", body, headers)
json.decodeFromString<AnimeSkipGraphqlResponse>(text)
} catch (_: Exception) {
null
}
}
}

View file

@ -0,0 +1,150 @@
package com.nuvio.app.features.player.skip
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun SkipIntroButton(
interval: SkipInterval?,
dismissed: Boolean,
controlsVisible: Boolean,
onSkip: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var lastType by remember { mutableStateOf(interval?.type) }
if (interval != null) lastType = interval.type
val shouldShow = interval != null && (!dismissed || controlsVisible)
var autoHidden by remember { mutableStateOf(false) }
var manuallyDismissed by remember { mutableStateOf(false) }
val progress = remember { Animatable(0f) }
LaunchedEffect(interval?.startTime, interval?.type) {
autoHidden = false
manuallyDismissed = false
progress.snapTo(0f)
}
LaunchedEffect(dismissed) {
if (!dismissed) {
if (!manuallyDismissed) {
autoHidden = false
progress.snapTo(0f)
}
} else {
manuallyDismissed = true
}
}
LaunchedEffect(shouldShow, autoHidden, controlsVisible) {
if (shouldShow && !autoHidden && !controlsVisible) {
progress.animateTo(
1f,
animationSpec = tween(
durationMillis = ((1f - progress.value) * 10000).toInt().coerceAtLeast(1),
easing = LinearEasing,
),
)
autoHidden = true
}
}
val isVisible = shouldShow && (!autoHidden || controlsVisible)
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(tween(300)) + scaleIn(tween(300), initialScale = 0.8f),
exit = fadeOut(tween(200)) + scaleOut(tween(200), targetScale = 0.8f),
modifier = modifier,
) {
val shape = RoundedCornerShape(16.dp)
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.clip(shape)
.background(Color(0xFF1E1E1E).copy(alpha = 0.85f))
.clickable { onSkip() },
) {
Row(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
Text(
text = getSkipLabel(lastType),
color = Color.White,
fontSize = 14.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp))
.background(Color.White.copy(alpha = if (controlsVisible || autoHidden || dismissed) 0f else 0.15f)),
) {
Box(
modifier = Modifier
.fillMaxWidth(progress.value)
.height(3.dp)
.background(
Color(0xFF1E1E1E).copy(
alpha = if (controlsVisible || autoHidden || dismissed) 0f else 0.85f,
),
),
)
}
}
}
}
private fun getSkipLabel(type: String?): String {
return when (type?.lowercase()) {
"intro", "op", "mixed-op" -> "Skip Intro"
"outro", "ed", "mixed-ed", "credits" -> "Skip Outro"
"recap" -> "Skip Recap"
else -> "Skip"
}
}

View file

@ -0,0 +1,249 @@
package com.nuvio.app.features.player.skip
import com.nuvio.app.features.player.PlayerSettingsRepository
object SkipIntroRepository {
private val cache = HashMap<String, List<SkipInterval>>()
private val imdbEntriesCache = HashMap<String, List<ArmEntry>>()
private val animeSkipShowIdCache = HashMap<String, String>()
private const val NO_ID = "__none__"
private val introDbConfigured: Boolean
get() = IntroDbConfig.URL.isNotBlank()
suspend fun getSkipIntervals(imdbId: String?, season: Int, episode: Int): List<SkipInterval> {
if (imdbId == null) return emptyList()
val settings = PlayerSettingsRepository.uiState.value
if (!settings.skipIntroEnabled) return emptyList()
val cacheKey = "$imdbId:$season:$episode"
cache[cacheKey]?.let { return it }
if (introDbConfigured) {
val result = fetchFromIntroDb(imdbId, season, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val entries = resolveImdbEntries(imdbId)
val malId = entries.getOrNull(season - 1)?.myanimelist?.toString()
?: entries.firstOrNull()?.myanimelist?.toString()
if (malId != null) {
val result = fetchFromAniSkip(malId, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
for ((anilistId, seasonFilter) in listOfNotNull(
seasonAnilistId?.let { it to null },
if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
)) {
val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
return emptyList<SkipInterval>().also { cache[cacheKey] = it }
}
suspend fun getSkipIntervalsForMal(malId: String, episode: Int): List<SkipInterval> {
val settings = PlayerSettingsRepository.uiState.value
if (!settings.skipIntroEnabled) return emptyList()
val cacheKey = "mal:$malId:$episode"
cache[cacheKey]?.let { return it }
val aniSkipResult = fetchFromAniSkip(malId, episode)
if (aniSkipResult.isNotEmpty()) return aniSkipResult.also { cache[cacheKey] = it }
val imdbId = try {
SkipIntroApi.resolveMalToImdb(malId)?.imdb
} catch (_: Exception) { null }
if (imdbId != null) {
val entries = resolveImdbEntries(imdbId)
val season = entries.indexOfFirst { it.myanimelist == malId.toIntOrNull() } + 1
if (introDbConfigured) {
val result = fetchFromIntroDb(imdbId, season, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
for ((anilistId, seasonFilter) in listOfNotNull(
seasonAnilistId?.let { it to null },
if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
)) {
val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
} else {
val anilistId = try {
SkipIntroApi.resolveMalToAnilist(malId)?.anilist?.toString()
} catch (_: Exception) { null }
if (anilistId != null) {
val result = fetchFromAnimeSkip(anilistId, episode, season = null)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
}
return emptyList<SkipInterval>().also { cache[cacheKey] = it }
}
suspend fun getSkipIntervalsForKitsu(kitsuId: String, episode: Int): List<SkipInterval> {
val settings = PlayerSettingsRepository.uiState.value
if (!settings.skipIntroEnabled) return emptyList()
val cacheKey = "kitsu:$kitsuId:$episode"
cache[cacheKey]?.let { return it }
val malId = try {
SkipIntroApi.resolveKitsuToMal(kitsuId)?.myanimelist?.toString()
} catch (_: Exception) { null }
if (malId != null) {
val result = fetchFromAniSkip(malId, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val imdbId = try {
SkipIntroApi.resolveKitsuToImdb(kitsuId)?.imdb
} catch (_: Exception) { null }
if (imdbId != null) {
val entries = resolveImdbEntries(imdbId)
val season = entries.indexOfFirst { it.kitsu == kitsuId.toIntOrNull() } + 1
if (introDbConfigured) {
val result = fetchFromIntroDb(imdbId, season, episode)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
val seasonAnilistId = entries.getOrNull(season - 1)?.anilist?.toString()
val fallbackAnilistId = entries.firstOrNull()?.anilist?.toString()
for ((anilistId, seasonFilter) in listOfNotNull(
seasonAnilistId?.let { it to null },
if (fallbackAnilistId != null && fallbackAnilistId != seasonAnilistId) fallbackAnilistId to season else null
)) {
val result = fetchFromAnimeSkip(anilistId, episode, season = seasonFilter)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
} else {
val anilistId = try {
SkipIntroApi.resolveKitsuToAnilist(kitsuId)?.anilist?.toString()
} catch (_: Exception) { null }
if (anilistId != null) {
val result = fetchFromAnimeSkip(anilistId, episode, season = null)
if (result.isNotEmpty()) return result.also { cache[cacheKey] = it }
}
}
return emptyList<SkipInterval>().also { cache[cacheKey] = it }
}
private suspend fun fetchFromIntroDb(imdbId: String, season: Int, episode: Int): List<SkipInterval> {
return try {
val data = SkipIntroApi.getIntroDbSegments(imdbId, season, episode)
if (data == null) return emptyList()
listOfNotNull(
data.intro.toSkipIntervalOrNull("intro"),
data.recap.toSkipIntervalOrNull("recap"),
data.outro.toSkipIntervalOrNull("outro"),
)
} catch (_: Exception) {
emptyList()
}
}
private fun IntroDbSegment?.toSkipIntervalOrNull(type: String): SkipInterval? {
if (this == null) return null
val start = startSec ?: startMs?.let { it / 1000.0 }
val end = endSec ?: endMs?.let { it / 1000.0 }
if (start == null || end == null || end <= start) return null
return SkipInterval(startTime = start, endTime = end, type = type, provider = "introdb")
}
private suspend fun fetchFromAniSkip(malId: String, episode: Int): List<SkipInterval> {
return try {
val response = SkipIntroApi.getAniSkipTimes(malId, episode)
if (response == null) return emptyList()
if (!response.found) return emptyList()
response.results?.map { result ->
SkipInterval(
startTime = result.interval.startTime,
endTime = result.interval.endTime,
type = result.skipType,
provider = "aniskip",
)
} ?: emptyList()
} catch (_: Exception) {
emptyList()
}
}
private suspend fun fetchFromAnimeSkip(anilistId: String, episode: Int, season: Int?): List<SkipInterval> {
val settings = PlayerSettingsRepository.uiState.value
val clientId = settings.animeSkipClientId.trim()
if (clientId.isBlank()) return emptyList()
if (!settings.animeSkipEnabled) return emptyList()
return try {
val showIds = resolveAnimeSkipShowIds(anilistId, clientId)
if (showIds.isEmpty()) return emptyList()
for (showId in showIds) {
val query = "{ findEpisodesByShowId(showId: \"$showId\") { season number timestamps { at type { name } } } }"
val response = SkipIntroApi.queryAnimeSkip(clientId, query) ?: continue
val episodes = response.data?.findEpisodesByShowId ?: continue
val targetEpisode = episodes.firstOrNull { ep ->
ep.number?.toIntOrNull() == episode &&
(season == null || ep.season?.toIntOrNull() == season)
} ?: continue
val sorted = (targetEpisode.timestamps ?: continue).sortedBy { it.at }
val result = sorted.mapIndexedNotNull { i, ts ->
val endTime = sorted.getOrNull(i + 1)?.at ?: Double.MAX_VALUE
val type = when (ts.type.name.lowercase()) {
"intro", "new intro" -> "op"
"credits" -> "ed"
"recap" -> "recap"
else -> return@mapIndexedNotNull null
}
SkipInterval(startTime = ts.at, endTime = endTime, type = type, provider = "animeskip")
}
if (result.isNotEmpty()) return result
}
emptyList()
} catch (_: Exception) {
emptyList()
}
}
private suspend fun resolveAnimeSkipShowIds(anilistId: String, clientId: String): List<String> {
animeSkipShowIdCache[anilistId]?.let { cached ->
return if (cached == NO_ID) emptyList() else listOf(cached)
}
val query = "{ findShowsByExternalId(service: ANILIST, serviceId: \"$anilistId\") { id } }"
val showIds = try {
SkipIntroApi.queryAnimeSkip(clientId, query)
?.data?.findShowsByExternalId?.map { it.id } ?: emptyList()
} catch (_: Exception) { emptyList() }
if (showIds.size == 1) animeSkipShowIdCache[anilistId] = showIds[0]
else if (showIds.isEmpty()) animeSkipShowIdCache[anilistId] = NO_ID
return showIds
}
private suspend fun resolveImdbEntries(imdbId: String): List<ArmEntry> {
imdbEntriesCache[imdbId]?.let { return it }
return try {
SkipIntroApi.resolveImdbToAll(imdbId)
} catch (_: Exception) { emptyList() }.also { imdbEntriesCache[imdbId] = it }
}
fun clearCache() {
cache.clear()
imdbEntriesCache.clear()
animeSkipShowIdCache.clear()
}
}

View file

@ -0,0 +1,118 @@
package com.nuvio.app.features.player.skip
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class SkipInterval(
val startTime: Double,
val endTime: Double,
val type: String,
val provider: String,
)
data class NextEpisodeInfo(
val videoId: String,
val season: Int,
val episode: Int,
val title: String,
val thumbnail: String?,
val overview: String?,
val released: String?,
val hasAired: Boolean,
val unairedMessage: String?,
)
enum class NextEpisodeThresholdMode {
PERCENTAGE,
MINUTES_BEFORE_END,
}
// --- IntroDb API response models ---
@Serializable
data class IntroDbSegmentsResponse(
@SerialName("imdb_id") val imdbId: String? = null,
@SerialName("season") val season: Int? = null,
@SerialName("episode") val episode: Int? = null,
@SerialName("intro") val intro: IntroDbSegment? = null,
@SerialName("recap") val recap: IntroDbSegment? = null,
@SerialName("outro") val outro: IntroDbSegment? = null,
)
@Serializable
data class IntroDbSegment(
@SerialName("start_sec") val startSec: Double? = null,
@SerialName("end_sec") val endSec: Double? = null,
@SerialName("start_ms") val startMs: Long? = null,
@SerialName("end_ms") val endMs: Long? = null,
@SerialName("confidence") val confidence: Double? = null,
@SerialName("submission_count") val submissionCount: Int? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)
// --- AniSkip API response models ---
@Serializable
data class AniSkipResponse(
@SerialName("found") val found: Boolean = false,
@SerialName("results") val results: List<AniSkipResult>? = null,
)
@Serializable
data class AniSkipResult(
@SerialName("interval") val interval: AniSkipInterval,
@SerialName("skipType") val skipType: String,
@SerialName("skipId") val skipId: String? = null,
)
@Serializable
data class AniSkipInterval(
@SerialName("startTime") val startTime: Double,
@SerialName("endTime") val endTime: Double,
)
// --- ARM API response models ---
@Serializable
data class ArmEntry(
@SerialName("myanimelist") val myanimelist: Int? = null,
@SerialName("anilist") val anilist: Int? = null,
@SerialName("kitsu") val kitsu: Int? = null,
@SerialName("imdb") val imdb: String? = null,
)
// --- Anime-Skip GraphQL API response models ---
@Serializable
data class AnimeSkipGraphqlResponse(
@SerialName("data") val data: AnimeSkipData? = null,
)
@Serializable
data class AnimeSkipData(
@SerialName("findShowsByExternalId") val findShowsByExternalId: List<AnimeSkipShow>? = null,
@SerialName("findEpisodesByShowId") val findEpisodesByShowId: List<AnimeSkipEpisode>? = null,
)
@Serializable
data class AnimeSkipShow(
@SerialName("id") val id: String,
)
@Serializable
data class AnimeSkipEpisode(
@SerialName("season") val season: String? = null,
@SerialName("number") val number: String? = null,
@SerialName("timestamps") val timestamps: List<AnimeSkipTimestamp>? = null,
)
@Serializable
data class AnimeSkipTimestamp(
@SerialName("at") val at: Double,
@SerialName("type") val type: AnimeSkipTimestampType,
)
@Serializable
data class AnimeSkipTimestampType(
@SerialName("name") val name: String,
)

View file

@ -367,6 +367,208 @@ private fun PlaybackSettingsSection(
}
}
}
SettingsSection(
title = "SKIP SEGMENTS",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Skip Intro/Outro/Recap",
description = "Show skip button during detected intro, outro, and recap segments.",
checked = autoPlayPlayerSettings.skipIntroEnabled,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setSkipIntroEnabled,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = "Anime Skip",
description = "Also search AnimeSkip for skip timestamps (requires client ID).",
checked = autoPlayPlayerSettings.animeSkipEnabled,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setAnimeSkipEnabled,
)
if (autoPlayPlayerSettings.animeSkipEnabled) {
SettingsGroupDivider(isTablet = isTablet)
var showAnimeSkipClientIdDialog by remember { mutableStateOf(false) }
SettingsNavigationRow(
title = "AnimeSkip Client ID",
description = autoPlayPlayerSettings.animeSkipClientId.ifBlank { "Not set" },
isTablet = isTablet,
onClick = { showAnimeSkipClientIdDialog = true },
)
if (showAnimeSkipClientIdDialog) {
AnimeSkipClientIdDialog(
initialValue = autoPlayPlayerSettings.animeSkipClientId,
onSave = {
PlayerSettingsRepository.setAnimeSkipClientId(it)
showAnimeSkipClientIdDialog = false
},
onDismiss = { showAnimeSkipClientIdDialog = false },
)
}
}
}
}
SettingsSection(
title = "NEXT EPISODE",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Auto-Play Next Episode",
description = "Automatically find and play the next episode when the threshold is reached.",
checked = autoPlayPlayerSettings.streamAutoPlayNextEpisodeEnabled,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayNextEpisodeEnabled,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = "Prefer Binge Group",
description = "When auto-playing, prefer a stream from the same binge group as the current one.",
checked = autoPlayPlayerSettings.streamAutoPlayPreferBingeGroup,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayPreferBingeGroup,
)
SettingsGroupDivider(isTablet = isTablet)
var showThresholdModeDialog by remember { mutableStateOf(false) }
SettingsNavigationRow(
title = "Threshold Mode",
description = when (autoPlayPlayerSettings.nextEpisodeThresholdMode) {
com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> "Percentage"
com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> "Minutes Before End"
},
isTablet = isTablet,
onClick = { showThresholdModeDialog = true },
)
if (showThresholdModeDialog) {
NextEpisodeThresholdModeDialog(
selected = autoPlayPlayerSettings.nextEpisodeThresholdMode,
onSelect = {
PlayerSettingsRepository.setNextEpisodeThresholdMode(it)
showThresholdModeDialog = false
},
onDismiss = { showThresholdModeDialog = false },
)
}
SettingsGroupDivider(isTablet = isTablet)
when (autoPlayPlayerSettings.nextEpisodeThresholdMode) {
com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> {
val thresholdPercent = autoPlayPlayerSettings.nextEpisodeThresholdPercent
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 18.dp else 16.dp, vertical = 10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Threshold Percentage",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Show next episode card when playback reaches this percentage.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "${thresholdPercent.toInt()}%",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
var sliderVal by remember(thresholdPercent) { mutableFloatStateOf(thresholdPercent) }
var lastHapticPercent by remember(thresholdPercent) { mutableStateOf(thresholdPercent.toInt()) }
Slider(
value = sliderVal,
onValueChange = {
sliderVal = it
val stepped = it.toInt()
if (stepped != lastHapticPercent) {
lastHapticPercent = stepped
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
},
onValueChangeFinished = {
PlayerSettingsRepository.setNextEpisodeThresholdPercent(sliderVal)
},
valueRange = 50f..100f,
steps = 49,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
val thresholdMinutes = autoPlayPlayerSettings.nextEpisodeThresholdMinutesBeforeEnd
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (isTablet) 18.dp else 16.dp, vertical = 10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Minutes Before End",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Show next episode card this many minutes before the end.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "${thresholdMinutes.toInt()} min",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
var sliderVal by remember(thresholdMinutes) { mutableFloatStateOf(thresholdMinutes) }
var lastHapticMin by remember(thresholdMinutes) { mutableStateOf(thresholdMinutes.toInt()) }
Slider(
value = sliderVal,
onValueChange = {
sliderVal = it
val stepped = it.toInt()
if (stepped != lastHapticMin) {
lastHapticMin = stepped
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
},
onValueChangeFinished = {
PlayerSettingsRepository.setNextEpisodeThresholdMinutesBeforeEnd(sliderVal)
},
valueRange = 1f..15f,
steps = 13,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
if (showPreferredAudioDialog) {
@ -1319,3 +1521,147 @@ private fun StreamAutoPlayRegexDialog(
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AnimeSkipClientIdDialog(
initialValue: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit,
) {
var value by remember { mutableStateOf(initialValue) }
BasicAlertDialog(onDismissRequest = 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 = "AnimeSkip Client ID",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Enter your AnimeSkip API client ID. Get one at anime-skip.com.",
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 = 0.3f)),
) {
BasicTextField(
value = value,
onValueChange = { value = it },
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,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = { onSave(value.trim()) }) { Text("Save") }
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun NextEpisodeThresholdModeDialog(
selected: com.nuvio.app.features.player.skip.NextEpisodeThresholdMode,
onSelect: (com.nuvio.app.features.player.skip.NextEpisodeThresholdMode) -> Unit,
onDismiss: () -> Unit,
) {
val options = com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.entries
BasicAlertDialog(onDismissRequest = 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 = "Threshold Mode",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
options.forEach { mode ->
val isSelected = mode == selected
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
val label = when (mode) {
com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> "Percentage"
com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> "Minutes Before End"
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(mode) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Tap outside to close",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View file

@ -24,6 +24,14 @@ actual object PlayerSettingsStorage {
private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins"
private const val streamAutoPlayRegexKey = "stream_auto_play_regex"
private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds"
private const val skipIntroEnabledKey = "skip_intro_enabled"
private const val animeSkipEnabledKey = "animeskip_enabled"
private const val animeSkipClientIdKey = "animeskip_client_id"
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"
private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2"
private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2"
actual fun loadShowLoadingOverlay(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
@ -280,4 +288,108 @@ actual object PlayerSettingsStorage {
actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) {
NSUserDefaults.standardUserDefaults.setInteger(seconds.toLong(), forKey = ProfileScopedKey.of(streamAutoPlayTimeoutSecondsKey))
}
actual fun loadSkipIntroEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(skipIntroEnabledKey)
return if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
null
}
}
actual fun saveSkipIntroEnabled(enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(skipIntroEnabledKey))
}
actual fun loadAnimeSkipEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(animeSkipEnabledKey)
return if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
null
}
}
actual fun saveAnimeSkipEnabled(enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(animeSkipEnabledKey))
}
actual fun loadAnimeSkipClientId(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(animeSkipClientIdKey)
return defaults.stringForKey(key)
}
actual fun saveAnimeSkipClientId(clientId: String) {
NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = ProfileScopedKey.of(animeSkipClientIdKey))
}
actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey)
return if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
null
}
}
actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey))
}
actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey)
return if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
null
}
}
actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey))
}
actual fun loadNextEpisodeThresholdMode(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(nextEpisodeThresholdModeKey)
return defaults.stringForKey(key)
}
actual fun saveNextEpisodeThresholdMode(mode: String) {
NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(nextEpisodeThresholdModeKey))
}
actual fun loadNextEpisodeThresholdPercent(): Float? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(nextEpisodeThresholdPercentKey)
return if (defaults.objectForKey(key) != null) {
defaults.floatForKey(key)
} else {
null
}
}
actual fun saveNextEpisodeThresholdPercent(percent: Float) {
NSUserDefaults.standardUserDefaults.setFloat(percent, forKey = ProfileScopedKey.of(nextEpisodeThresholdPercentKey))
}
actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(nextEpisodeThresholdMinutesBeforeEndKey)
return if (defaults.objectForKey(key) != null) {
defaults.floatForKey(key)
} else {
null
}
}
actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) {
NSUserDefaults.standardUserDefaults.setFloat(minutes, forKey = ProfileScopedKey.of(nextEpisodeThresholdMinutesBeforeEndKey))
}
}

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.player.skip
import platform.Foundation.NSCalendar
import platform.Foundation.NSCalendarUnitDay
import platform.Foundation.NSCalendarUnitMonth
import platform.Foundation.NSCalendarUnitYear
import platform.Foundation.NSDate
internal actual fun currentDateComponents(): DateComponents {
val cal = NSCalendar.currentCalendar
val components = cal.components(
NSCalendarUnitYear or NSCalendarUnitMonth or NSCalendarUnitDay,
fromDate = NSDate(),
)
return DateComponents(
year = components.year.toInt(),
month = components.month.toInt(),
day = components.day.toInt(),
)
}