mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-04 17:29:07 +00:00
feat: adding skip intro/next episode logic
This commit is contained in:
parent
5ecb5b8131
commit
49a178c7f9
15 changed files with 1935 additions and 0 deletions
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue