diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 65f34d30..1393d610 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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() + ) + } } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 44b07185..69ac7593 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -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() + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.android.kt new file mode 100644 index 00000000..bc7177bd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.android.kt @@ -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), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 030c645e..29518543 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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>(emptyList()) } + var activeSkipInterval by remember { mutableStateOf(null) } + var skipIntervalDismissed by remember { mutableStateOf(false) } + + // Next episode state + var nextEpisodeInfo by remember { mutableStateOf(null) } + var showNextEpisodeCard by remember { mutableStateOf(false) } + var nextEpisodeAutoPlaySearching by remember { mutableStateOf(false) } + var nextEpisodeAutoPlaySourceName by remember { mutableStateOf(null) } + var nextEpisodeAutoPlayCountdown by remember { mutableStateOf(null) } + var nextEpisodeAutoPlayJob by remember { mutableStateOf(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(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index f5682e33..b380fd17 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -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 = 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 = 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 72a73334..d0965bb3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -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) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt new file mode 100644 index 00000000..5cc44184 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt @@ -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), + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt new file mode 100644 index 00000000..a703251d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt @@ -0,0 +1,83 @@ +package com.nuvio.app.features.player.skip + +import com.nuvio.app.features.details.MetaVideo + +object PlayerNextEpisodeRules { + + fun resolveNextEpisode( + videos: List, + currentSeason: Int?, + currentEpisode: Int?, + ): MetaVideo? { + if (currentSeason == null || currentEpisode == null) return null + val sortedEpisodes = videos + .filter { it.season != null && it.episode != null } + .sortedWith( + compareBy { 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, + 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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt new file mode 100644 index 00000000..87b7edfe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt @@ -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(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(text) + } catch (_: Exception) { + null + } + } + + // --- ARM API (ID resolution) --- + + suspend fun resolveImdbToAll(imdbId: String): List { + val url = "${ARM_BASE}imdb?id=$imdbId&include=myanimelist,anilist,kitsu" + return try { + val text = httpGetText(url) + json.decodeFromString>(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(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(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(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(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(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(text) + } catch (_: Exception) { + null + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt new file mode 100644 index 00000000..94a187ad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt @@ -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" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt new file mode 100644 index 00000000..4e8ebf68 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt @@ -0,0 +1,249 @@ +package com.nuvio.app.features.player.skip + +import com.nuvio.app.features.player.PlayerSettingsRepository + +object SkipIntroRepository { + + private val cache = HashMap>() + private val imdbEntriesCache = HashMap>() + private val animeSkipShowIdCache = HashMap() + private const val NO_ID = "__none__" + + private val introDbConfigured: Boolean + get() = IntroDbConfig.URL.isNotBlank() + + suspend fun getSkipIntervals(imdbId: String?, season: Int, episode: Int): List { + 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().also { cache[cacheKey] = it } + } + + suspend fun getSkipIntervalsForMal(malId: String, episode: Int): List { + 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().also { cache[cacheKey] = it } + } + + suspend fun getSkipIntervalsForKitsu(kitsuId: String, episode: Int): List { + 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().also { cache[cacheKey] = it } + } + + private suspend fun fetchFromIntroDb(imdbId: String, season: Int, episode: Int): List { + 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 { + 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 { + 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 { + 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 { + 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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt new file mode 100644 index 00000000..c97d27f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt @@ -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? = 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? = null, + @SerialName("findEpisodesByShowId") val findEpisodesByShowId: List? = 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? = null, +) + +@Serializable +data class AnimeSkipTimestamp( + @SerialName("at") val at: Double, + @SerialName("type") val type: AnimeSkipTimestampType, +) + +@Serializable +data class AnimeSkipTimestampType( + @SerialName("name") val name: String, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 0e8e941b..26301ebf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -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, + ) + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 0ee55c3d..33a9c354 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -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)) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.ios.kt new file mode 100644 index 00000000..b760af14 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.ios.kt @@ -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(), + ) +}