From 044a1256942bb788e0a74ff3895b242569eb5ecb Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 17 May 2026 19:41:56 +0530 Subject: [PATCH 01/19] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 11aa4529..4dfd8633 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=62 -MARKETING_VERSION=0.1.0 +CURRENT_PROJECT_VERSION=63 +MARKETING_VERSION=0.1.21 From 938d33eb75f3275f56585076e20c41f3459fe76b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 18 May 2026 11:57:58 +0530 Subject: [PATCH 02/19] ref: progress key generation for rpc sync --- .../features/watching/sync/SupabaseProgressSyncAdapter.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index cb2dc940..c1099d50 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -50,6 +50,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { position = entry.lastPositionMs, duration = entry.durationMs, lastWatched = entry.lastUpdatedEpochMs, + progressKey = progressKeyForEntry(entry), ) } val params = buildJsonObject { @@ -76,6 +77,13 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { } SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } + + private fun progressKeyForEntry(entry: WatchProgressEntry): String = + if (entry.seasonNumber != null && entry.episodeNumber != null) { + "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" + } else { + entry.parentMetaId + } } @Serializable From 40941511080fe92c2638089f2add5dc95fc9547c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 18 May 2026 22:15:11 +0530 Subject: [PATCH 03/19] ref: potential audio channel related crash fix --- iosApp/iosApp/Player/MPVPlayerBridge.swift | 29 ++++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 06779ac2..39ec6e2b 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -273,6 +273,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "hwdec", "auto")) + checkError(mpv_set_option_string(mpv, "audio-channels", "stereo")) + checkError(mpv_set_option_string(mpv, "audio-fallback-to-null", "yes")) checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo")) checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1")) checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no")) @@ -312,12 +314,12 @@ final class MPVPlayerViewController: UIViewController { @objc private func enterBackground() { guard mpv != nil else { return } pausePlayback() - checkError(mpv_set_option_string(mpv, "vid", "no")) + setStringProperty("vid", "no") } @objc private func enterForeground() { guard mpv != nil else { return } - checkError(mpv_set_option_string(mpv, "vid", "auto")) + setStringProperty("vid", "auto") playPlayback() } @@ -443,14 +445,14 @@ final class MPVPlayerViewController: UIViewController { guard mpv != nil else { return } switch mode { case 1: // Fill - checkError(mpv_set_option_string(mpv, "panscan", "1.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + setStringProperty("panscan", "1.0") + setStringProperty("video-unscaled", "no") case 2: // Zoom - checkError(mpv_set_option_string(mpv, "panscan", "1.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + setStringProperty("panscan", "1.0") + setStringProperty("video-unscaled", "no") default: // Fit - checkError(mpv_set_option_string(mpv, "panscan", "0.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + setStringProperty("panscan", "0.0") + setStringProperty("video-unscaled", "no") } } @@ -465,7 +467,7 @@ final class MPVPlayerViewController: UIViewController { func selectSubtitle(_ trackId: Int) { guard mpv != nil else { return } if trackId < 0 { - checkError(mpv_set_option_string(mpv, "sid", "no")) + setStringProperty("sid", "no") } else { var id = Int64(trackId) mpv_set_property(mpv, "sid", MPV_FORMAT_INT64, &id) @@ -488,7 +490,7 @@ final class MPVPlayerViewController: UIViewController { command("sub-remove", args: ["\(id)"], checkForErrors: false) } } - checkError(mpv_set_option_string(mpv, "sid", "no")) + setStringProperty("sid", "no") } func removeExternalSubtitlesAndSelect(_ trackId: Int) { @@ -505,7 +507,7 @@ final class MPVPlayerViewController: UIViewController { if trackId >= 0 { selectSubtitle(trackId) } else { - checkError(mpv_set_option_string(mpv, "sid", "no")) + setStringProperty("sid", "no") } } @@ -824,6 +826,11 @@ final class MPVPlayerViewController: UIViewController { mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data) } + private func setStringProperty(_ name: String, _ value: String) { + guard mpv != nil else { return } + checkError(mpv_set_property_string(mpv, name, value)) + } + private func getInt(_ name: String) -> Int { guard mpv != nil else { return 0 } var data = Int64() From cff9512d47d3d8e36477d86f4a9adde908a0aee4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 01:11:16 +0530 Subject: [PATCH 04/19] fix: multiple trakt cw stale cache and pull logic --- .../com/nuvio/app/features/home/HomeScreen.kt | 483 ++++++++++++++++-- .../features/trakt/TraktProgressRepository.kt | 293 ++++++++++- .../sync/SupabaseProgressSyncAdapter.kt | 67 +++ .../watchprogress/WatchProgressModels.kt | 1 + .../watchprogress/WatchProgressRepository.kt | 284 ++++++++-- 5 files changed, 1027 insertions(+), 101 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index aa4be057..5df53b1c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Logger import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -35,6 +36,7 @@ import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.shouldUseTraktProgress +import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem @@ -45,13 +47,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle +import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem -import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository @@ -164,46 +172,100 @@ fun HomeScreen( if (isTraktProgressActive) emptyList() else watchedUiState.items } - val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) { - WatchingState.latestCompletedBySeries( - progressEntries = effectiveWatchProgressEntries, + val allNextUpSeedEntries = remember( + watchProgressUiState.entries, + effectiveWatchedItems, + isTraktProgressActive, + continueWatchingPreferences.upNextFromFurthestEpisode, + ) { + buildTvParityNextUpSeedEntries( + progressEntries = watchProgressUiState.entries, watchedItems = effectiveWatchedItems, + isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val completedSeriesCandidates = remember(latestCompletedBySeries) { - latestCompletedBySeries.map { (content, completed) -> + + val recentNextUpSeedEntries = remember( + allNextUpSeedEntries, + isTraktProgressActive, + traktSettingsUiState.continueWatchingDaysCap, + ) { + filterEntriesForTraktContinueWatchingWindow( + entries = allNextUpSeedEntries, + isTraktProgressActive = isTraktProgressActive, + daysCap = traktSettingsUiState.continueWatchingDaysCap, + nowEpochMs = WatchProgressClock.nowEpochMs(), + ) + } + + val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) { + allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId } + } + + val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) { + allNextUpSeedEntries.mapNotNull { entry -> + val season = entry.seasonNumber ?: return@mapNotNull null + val episode = entry.episodeNumber ?: return@mapNotNull null + entry.parentMetaId to (season to episode) + }.toMap() + } + + val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) { + effectiveWatchProgressEntries.continueWatchingEntries() + } + + val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { + allNextUpSeedEntries + .groupBy { entry -> entry.parentMetaId } + .mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE } + } + + val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) { + visibleContinueWatchingEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> + shouldTreatAsActiveInProgressForNextUpSuppression( + progress = entry, + latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId], + ) + } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() + } + + val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) { + recentNextUpSeedEntries.mapNotNull { seed -> + val season = seed.seasonNumber ?: return@mapNotNull null + val episode = seed.episodeNumber ?: return@mapNotNull null + if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null CompletedSeriesCandidate( - content = content, - seasonNumber = completed.seasonNumber, - episodeNumber = completed.episodeNumber, - markedAtEpochMs = completed.markedAtEpochMs, + content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId), + seasonNumber = season, + episodeNumber = episode, + markedAtEpochMs = seed.lastUpdatedEpochMs, ) } } - val completedSeriesContentIds = remember(completedSeriesCandidates) { - completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } - } - val visibleContinueWatchingEntries = remember( - effectiveWatchProgressEntries, - latestCompletedBySeries, - ) { - WatchingState.visibleContinueWatchingEntries( - progressEntries = effectiveWatchProgressEntries, - latestCompletedBySeries = latestCompletedBySeries, - ) - } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf>>(emptyMap()) } + var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } val cachedNextUpItems = remember( cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys, - completedSeriesContentIds, + activeNextUpSeedContentIds, + currentNextUpSeedByContentId, isTraktProgressActive, + watchProgressUiState.hasLoadedRemoteProgress, + processedNextUpContentIds, + nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, watchedUiState.isLoaded, ) { @@ -211,7 +273,32 @@ fun HomeScreen( if ( !isTraktProgressActive && watchedUiState.isLoaded && - cached.contentId !in completedSeriesContentIds + cached.contentId !in activeNextUpSeedContentIds + ) { + return@mapNotNull null + } + if ( + isTraktProgressActive && + watchProgressUiState.hasLoadedRemoteProgress && + cached.contentId !in activeNextUpSeedContentIds + ) { + return@mapNotNull null + } + val currentSeed = currentNextUpSeedByContentId[cached.contentId] + if ( + currentSeed != null && + cached.seedSeason != null && + cached.seedEpisode != null + ) { + val (currentSeason, currentEpisode) = currentSeed + val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode + if (seedChanged) return@mapNotNull null + } + if ( + isTraktProgressActive && + watchProgressUiState.hasLoadedRemoteProgress && + cached.contentId in processedNextUpContentIds && + cached.contentId !in nextUpItemsBySeries.keys ) { return@mapNotNull null } @@ -257,16 +344,55 @@ fun HomeScreen( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, + nextUpSuppressedSeriesIds, continueWatchingPreferences.sortMode, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, cachedInProgressByVideoId = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, + nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, todayIsoDate = CurrentDateProvider.todayIsoDate(), ) } + LaunchedEffect( + isTraktProgressActive, + traktSettingsUiState.continueWatchingDaysCap, + watchProgressUiState.hasLoadedRemoteProgress, + continueWatchingPreferences.upNextFromFurthestEpisode, + watchProgressUiState.entries, + effectiveWatchProgressEntries, + allNextUpSeedEntries, + recentNextUpSeedEntries, + nextUpSuppressedSeriesIds, + visibleContinueWatchingEntries, + completedSeriesCandidates, + cachedInProgressItems, + cachedNextUpItems, + nextUpItemsBySeries, + processedNextUpContentIds, + effectivNextUpItems, + continueWatchingItems, + ) { + homeCwLog.d { + "build summary source=${if (isTraktProgressActive) "trakt" else "nuvio_sync"} " + + "remoteLoaded=${watchProgressUiState.hasLoadedRemoteProgress} " + + "daysCap=${traktSettingsUiState.continueWatchingDaysCap} " + + "raw=${watchProgressUiState.entries.size} rawSources=${watchProgressUiState.entries.debugSourceCounts()} " + + "effective=${effectiveWatchProgressEntries.size} seedAll=${allNextUpSeedEntries.size} " + + "seedRecent=${recentNextUpSeedEntries.size} seedSuppressed=${nextUpSuppressedSeriesIds.size} " + + "useFurthest=${continueWatchingPreferences.upNextFromFurthestEpisode} " + + "visibleInProgress=${visibleContinueWatchingEntries.size} " + + "completedCandidates=${completedSeriesCandidates.size} cachedInProgress=${cachedInProgressItems.size} " + + "cachedNextUp=${cachedNextUpItems.size} liveNextUp=${nextUpItemsBySeries.size} " + + "processedNextUp=${processedNextUpContentIds.size} " + + "effectiveNextUp=${effectivNextUpItems.size} final=${continueWatchingItems.size} " + + "rawItems=${watchProgressUiState.entries.debugWatchProgressSummary()} " + + "completed=${completedSeriesCandidates.debugCompletedSeriesSummary()} " + + "finalItems=${continueWatchingItems.debugContinueWatchingSummary()}" + } + } val availableManifests = remember(addonsUiState.addons) { addonsUiState.addons.mapNotNull { addon -> addon.manifest } } @@ -308,37 +434,75 @@ fun HomeScreen( continueWatchingPreferences.showUnairedNextUp, ) { if (completedSeriesCandidates.isEmpty()) { + homeCwLog.d { + "next-up resolve skipped: no completed series candidates " + + "entries=${effectiveWatchProgressEntries.size} sources=${effectiveWatchProgressEntries.debugSourceCounts()}" + } nextUpItemsBySeries = emptyMap() + processedNextUpContentIds = emptySet() return@LaunchedEffect } - if (metaProviderKey.isEmpty()) return@LaunchedEffect + if (metaProviderKey.isEmpty()) { + homeCwLog.d { + "next-up resolve deferred: no meta providers candidates=${completedSeriesCandidates.size} " + + "candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}" + } + return@LaunchedEffect + } val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(4) + homeCwLog.d { + "next-up resolve start candidates=${completedSeriesCandidates.size} " + + "showUnaired=${continueWatchingPreferences.showUnairedNextUp} " + + "metaProviders=${metaProviderKey.size} candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}" + } val results = completedSeriesCandidates.map { completedEntry -> async { semaphore.withPermit { val meta = MetaDetailsRepository.fetch( type = completedEntry.content.type, id = completedEntry.content.id, - ) ?: return@withPermit null + ) + if (meta == null) { + homeCwLog.d { + "next-up meta miss content=${completedEntry.debugSummary()} " + + "type=${completedEntry.content.type} id=${completedEntry.content.id}" + } + return@withPermit null + } val nextEpisode = meta.nextReleasedEpisodeAfter( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, - ) ?: return@withPermit null + ) + if (nextEpisode == null) { + homeCwLog.d { + "next-up no next episode content=${completedEntry.debugSummary()} " + + "videos=${meta.videos.size}" + } + return@withPermit null + } val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) { + homeCwLog.d { "next-up dismissed item=${item.debugSummary()}" } return@withPermit null } + homeCwLog.d { + "next-up built seed=${completedEntry.debugSummary()} item=${item.debugSummary()} " + + "released=${nextEpisode.released}" + } completedEntry.content.id to (completedEntry.markedAtEpochMs to item) } } }.awaitAll().filterNotNull().toMap() nextUpItemsBySeries = results + processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> + candidate.content.id + } val nextUpCache = results.mapNotNull { (contentId, pair) -> val item = pair.second @@ -389,6 +553,10 @@ fun HomeScreen( nextUp = nextUpCache, inProgress = inProgressCache, ) + homeCwLog.d { + "next-up resolve complete results=${results.size} nextUpCache=${nextUpCache.size} " + + "inProgressCache=${inProgressCache.size} items=${results.values.map { it.second }.debugContinueWatchingSummary()}" + } } val hasActiveAddons = addonsUiState.addons.any { it.manifest != null } @@ -615,6 +783,8 @@ fun HomeScreen( private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L +private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L +private val homeCwLog = Logger.withTag("HomeCW") internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, @@ -630,6 +800,169 @@ internal fun filterEntriesForTraktContinueWatchingWindow( return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } } +private fun buildTvParityNextUpSeedEntries( + progressEntries: List, + watchedItems: List, + isTraktProgressActive: Boolean, + preferFurthestEpisode: Boolean, + nowEpochMs: Long, +): List { + val rawSeeds = if (isTraktProgressActive) { + progressEntries.asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } + .filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) } + .toList() + } else { + watchedItems.asSequence() + .filter { item -> item.type.isSeriesTypeForContinueWatching() } + .filter { item -> item.season != null && item.episode != null && item.season != 0 } + .filter { item -> !isMalformedNextUpSeedContentId(item.id) } + .map { item -> item.toNextUpSeedEntry() } + .toList() + } + + return if (isTraktProgressActive) { + mergeTvTraktNextUpSeeds(rawSeeds) + } else { + rawSeeds + .groupBy { entry -> nextUpSeedKey(entry) } + .mapNotNull { (_, entries) -> + choosePreferredNextUpSeed( + entries = entries, + preferFurthestEpisode = preferFurthestEpisode, + ) + } + .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + } +} + +private fun shouldUseAsTraktNextUpSeed( + entry: WatchProgressEntry, + nowEpochMs: Long, +): Boolean { + if (!entry.shouldUseAsCompletedSeedForContinueWatching()) return false + if (entry.source != WatchProgressSourceTraktPlayback) return true + + val ageMs = nowEpochMs - entry.lastUpdatedEpochMs + return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS +} + +private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry = + WatchProgressEntry( + contentType = type, + parentMetaId = id, + parentMetaType = type, + videoId = id, + title = name, + poster = poster, + seasonNumber = season, + episodeNumber = episode, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = markedAtEpochMs, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceLocal, + ) + +private fun nextUpSeedKey(entry: WatchProgressEntry): String = + entry.parentMetaId.trim() + +private fun mergeTvTraktNextUpSeeds(entries: List): List { + val merged = linkedMapOf() + entries + .filter { entry -> entry.source == WatchProgressSourceTraktShowProgress } + .forEach { seed -> + merged[nextUpSeedKey(seed)] = seed + } + entries + .filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback } + .forEach { seed -> + val key = nextUpSeedKey(seed) + val existing = merged[key] + if (existing == null || shouldReplaceNextUpSeed(existing, seed)) { + merged[key] = seed + } + } + return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs } +} + +private fun shouldReplaceNextUpSeed( + existing: WatchProgressEntry, + candidate: WatchProgressEntry, +): Boolean { + val candidateSeason = candidate.seasonNumber ?: -1 + val candidateEpisode = candidate.episodeNumber ?: -1 + val existingSeason = existing.seasonNumber ?: -1 + val existingEpisode = existing.episodeNumber ?: -1 + return candidateSeason > existingSeason || + ( + candidateSeason == existingSeason && + ( + candidateEpisode > existingEpisode || + ( + candidateEpisode == existingEpisode && + candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs + ) + ) + ) +} + +private fun choosePreferredNextUpSeed( + entries: List, + preferFurthestEpisode: Boolean, +): WatchProgressEntry? { + if (entries.isEmpty()) return null + val bestRank = entries.minOf(::nextUpSeedSourceRank) + return entries + .asSequence() + .filter { entry -> nextUpSeedSourceRank(entry) == bestRank } + .maxWithOrNull( + if (preferFurthestEpisode) { + compareBy( + { it.seasonNumber ?: -1 }, + { it.episodeNumber ?: -1 }, + { it.lastUpdatedEpochMs }, + ) + } else { + compareBy( + { it.lastUpdatedEpochMs }, + { it.seasonNumber ?: -1 }, + { it.episodeNumber ?: -1 }, + ) + }, + ) +} + +private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int = + when (entry.source) { + WatchProgressSourceTraktPlayback, + WatchProgressSourceTraktShowProgress, + -> 0 + WatchProgressSourceTraktHistory -> 1 + WatchProgressSourceLocal -> 2 + else -> 4 + } + +private fun shouldTreatAsActiveInProgressForNextUpSuppression( + progress: WatchProgressEntry, + latestCompletedAt: Long?, +): Boolean { + if (!progress.shouldTreatAsInProgressForContinueWatching()) return false + if (latestCompletedAt == null || latestCompletedAt == Long.MIN_VALUE) return true + return progress.lastUpdatedEpochMs >= latestCompletedAt +} + +private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { + val trimmed = contentId?.trim().orEmpty() + if (trimmed.isEmpty()) return true + return when (trimmed.lowercase()) { + "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true + else -> false + } +} + private fun heroMobileBelowSectionHeightHint( maxWidthDp: Float, continueWatchingVisible: Boolean, @@ -652,15 +985,17 @@ internal fun buildHomeContinueWatchingItems( visibleEntries: List, cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, + nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, todayIsoDate: String = "", ): List { - val inProgressSeriesIds = visibleEntries - .asSequence() - .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } - .map { entry -> entry.parentMetaId } - .filter(String::isNotBlank) - .toSet() + val suppressedSeriesIds = nextUpSuppressedSeriesIds + ?: visibleEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() val candidates = buildList { addAll( @@ -675,7 +1010,7 @@ internal fun buildHomeContinueWatchingItems( ) addAll( nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) -> - if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null + if (item.parentMetaId in suppressedSeriesIds) return@mapNotNull null HomeContinueWatchingCandidate( lastUpdatedEpochMs = lastUpdatedEpochMs, item = item, @@ -866,3 +1201,83 @@ private fun ContinueWatchingItem.withFallbackMetadata( released = released ?: fallback.released, ) } + +private fun WatchProgressEntry.debugSummary(): String = + buildString { + append(parentMetaType) + append(":") + append(parentMetaId) + if (seasonNumber != null || episodeNumber != null) { + append(" s=") + append(seasonNumber) + append(" e=") + append(episodeNumber) + } + append(" video=") + append(videoId) + append(" pct=") + append(progressPercent) + append(" completed=") + append(isCompleted) + append(" effectiveCompleted=") + append(isEffectivelyCompleted) + append(" src=") + append(source) + append(" last=") + append(lastUpdatedEpochMs) + } + +private fun Collection.debugWatchProgressSummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } + +private fun Collection.debugSourceCounts(): String = + groupingBy { it.source } + .eachCount() + .entries + .sortedBy { it.key } + .joinToString(separator = ",") { "${it.key}=${it.value}" } + .ifBlank { "none" } + +private fun CompletedSeriesCandidate.debugSummary(): String = + buildString { + append(content.type) + append(":") + append(content.id) + append(" s=") + append(seasonNumber) + append(" e=") + append(episodeNumber) + append(" marked=") + append(markedAtEpochMs) + } + +private fun Collection.debugCompletedSeriesSummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } + +private fun ContinueWatchingItem.debugSummary(): String = + buildString { + append(if (isNextUp) "next_up" else "in_progress") + append(":") + append(parentMetaType) + append(":") + append(parentMetaId) + if (seasonNumber != null || episodeNumber != null) { + append(" s=") + append(seasonNumber) + append(" e=") + append(episodeNumber) + } + append(" video=") + append(videoId) + append(" seed=") + append(nextUpSeedSeasonNumber) + append("x") + append(nextUpSeedEpisodeNumber) + append(" progress=") + append(progressFraction) + append(" resume=") + append(resumePositionMs) + } + +private fun Collection.debugContinueWatchingSummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index dc43c983..6d9f99ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -22,7 +23,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -44,6 +47,7 @@ data class TraktProgressUiState( val entries: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null, + val hasLoadedRemoteProgress: Boolean = false, ) object TraktProgressRepository { @@ -56,6 +60,8 @@ object TraktProgressRepository { private var hasLoaded = false private var refreshRequestId: Long = 0L + private val refreshJobMutex = Mutex() + private var inFlightRefresh: Deferred? = null fun ensureLoaded() { if (hasLoaded) return @@ -82,14 +88,35 @@ object TraktProgressRepository { } suspend fun refreshNow() { + ensureLoaded() + val refresh = refreshJobMutex.withLock { + inFlightRefresh?.takeIf { it.isActive } ?: scope.async { + refreshNowInternal() + }.also { inFlightRefresh = it } + } + + try { + refresh.await() + } finally { + refreshJobMutex.withLock { + if (inFlightRefresh == refresh && refresh.isCompleted) { + inFlightRefresh = null + } + } + } + } + + private suspend fun refreshNowInternal() { ensureLoaded() val requestId = nextRefreshRequestId() val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { + log.d { "refreshNow request=$requestId skipped: missing authorized headers" } _uiState.value = TraktProgressUiState() return } + log.d { "refreshNow request=$requestId start currentEntries=${_uiState.value.entries.size}" } _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) val playbackEntries = runCatching { @@ -109,34 +136,56 @@ object TraktProgressRepository { _uiState.value = TraktProgressUiState( entries = playbackEntries, - isLoading = false, + isLoading = true, errorMessage = null, + hasLoadedRemoteProgress = false, ) + log.d { + "refreshNow request=$requestId playback applied entries=${playbackEntries.size} " + + "sources=${playbackEntries.debugSourceCounts()} items=${playbackEntries.debugWatchProgressSummary()}" + } if (playbackEntries.isNotEmpty()) { launchHydration(requestId = requestId, entries = playbackEntries) } - scope.launch { - val completedEntries = runCatching { - fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } - }.getOrNull() ?: return@launch + val completedEntries = runCatching { + coroutineScope { + val history = async { fetchHistoryEntries(headers) } + val watchedShowSeeds = async { fetchWatchedShowSeedEntries(headers) } + history.await() + watchedShowSeeds.await() + } + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } + }.getOrNull() - if (!isLatestRefreshRequest(requestId)) return@launch - - val merged = mergeNewestByVideoId(playbackEntries + completedEntries) + if (completedEntries == null) { _uiState.value = _uiState.value.copy( - entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, isLoading = false, errorMessage = null, + hasLoadedRemoteProgress = false, ) + return + } - if (merged.isNotEmpty()) { - launchHydration(requestId = requestId, entries = merged) - } + if (!isLatestRefreshRequest(requestId)) return + + val merged = mergeNewestByVideoId(playbackEntries + completedEntries) + _uiState.value = _uiState.value.copy( + entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, + isLoading = false, + errorMessage = null, + hasLoadedRemoteProgress = true, + ) + log.d { + "refreshNow request=$requestId completed snapshot applied " + + "completedEntries=${completedEntries.size} merged=${merged.size} " + + "sources=${merged.debugSourceCounts()} items=${merged.debugWatchProgressSummary()}" + } + + if (merged.isNotEmpty()) { + launchHydration(requestId = requestId, entries = merged) } } @@ -163,6 +212,10 @@ object TraktProgressRepository { isLoading = false, errorMessage = null, ) + log.d { + "hydrate request=$requestId applied hydrated=${hydrated.size} merged=${merged.size} " + + "items=${merged.debugWatchProgressSummary()}" + } } } @@ -175,6 +228,10 @@ object TraktProgressRepository { current[normalizedEntry.videoId] = normalizedEntry } _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) + log.d { + "optimistic progress applied entry=${normalizedEntry.debugSummary()} " + + "entries=${_uiState.value.entries.size}" + } } fun applyOptimisticRemoval(videoId: String) { @@ -182,6 +239,7 @@ object TraktProgressRepository { if (videoId.isBlank()) return val filtered = _uiState.value.entries.filterNot { it.videoId == videoId } _uiState.value = _uiState.value.copy(entries = filtered) + log.d { "optimistic removal videoId=$videoId entries=${filtered.size}" } } fun applyOptimisticRemoval( @@ -202,6 +260,10 @@ object TraktProgressRepository { } } _uiState.value = _uiState.value.copy(entries = filtered) + log.d { + "optimistic removal contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber " + + "entries=${filtered.size}" + } } suspend fun removeProgress( @@ -213,6 +275,7 @@ object TraktProgressRepository { if (normalizedContentId.isBlank()) return val headers = TraktAuthRepository.authorizedHeaders() ?: return + log.d { "removeProgress start contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber" } applyOptimisticRemoval( contentId = normalizedContentId, seasonNumber = seasonNumber, @@ -280,10 +343,12 @@ object TraktProgressRepository { } } + log.d { "removeProgress complete contentId=$normalizedContentId refreshing" } refreshNow() } private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { + log.d { "fetchPlaybackEntries start" } val payloads = coroutineScope { val moviesPayload = async { httpGetTextWithHeaders( @@ -306,6 +371,10 @@ object TraktProgressRepository { val moviePlayback = json.decodeFromString>(moviesPayload) val episodePlayback = json.decodeFromString>(episodesPayload) + log.d { + "fetchPlaybackEntries raw movies=${moviePlayback.size} episodes=${episodePlayback.size} " + + "movieItems=${moviePlayback.debugPlaybackSummary()} episodeItems=${episodePlayback.debugPlaybackSummary()}" + } val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item -> mapPlaybackMovie(item = item, fallbackIndex = index) @@ -314,10 +383,16 @@ object TraktProgressRepository { mapPlaybackEpisode(item = item, fallbackIndex = index) } - mergeNewestByVideoId(inProgressMovies + inProgressEpisodes) + val merged = mergeNewestByVideoId(inProgressMovies + inProgressEpisodes) + log.d { + "fetchPlaybackEntries mapped movies=${inProgressMovies.size} episodes=${inProgressEpisodes.size} " + + "merged=${merged.size} items=${merged.debugWatchProgressSummary()}" + } + merged } private suspend fun fetchHistoryEntries(headers: Map): List = withContext(Dispatchers.Default) { + log.d { "fetchHistoryEntries start limit=$HISTORY_LIMIT" } val payloads = coroutineScope { val historyPayload = async { httpGetTextWithHeaders( @@ -339,6 +414,10 @@ object TraktProgressRepository { val movieHistoryPayload = payloads[1] val episodeHistory = json.decodeFromString>(historyPayload) val movieHistory = json.decodeFromString>(movieHistoryPayload) + log.d { + "fetchHistoryEntries raw episodes=${episodeHistory.size} movies=${movieHistory.size} " + + "episodeItems=${episodeHistory.debugHistoryEpisodeSummary()} movieItems=${movieHistory.debugHistoryMovieSummary()}" + } val completedEpisodes = episodeHistory .mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) } @@ -347,7 +426,12 @@ object TraktProgressRepository { .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .distinctBy { entry -> entry.videoId } - mergeNewestByVideoId(completedEpisodes + completedMovies) + val merged = mergeNewestByVideoId(completedEpisodes + completedMovies) + log.d { + "fetchHistoryEntries mapped episodes=${completedEpisodes.size} movies=${completedMovies.size} " + + "merged=${merged.size} items=${merged.debugWatchProgressSummary()}" + } + merged } private suspend fun fetchWatchedShowSeedEntries( @@ -360,7 +444,11 @@ object TraktProgressRepository { headers = headers, ) val watchedShows = json.decodeFromString>(payload) - watchedShows + log.d { + "fetchWatchedShowSeedEntries raw shows=${watchedShows.size} " + + "items=${watchedShows.debugWatchedShowSummary()}" + } + val mapped = watchedShows .mapNotNull { item -> mapWatchedShowSeed( item = item, @@ -368,6 +456,11 @@ object TraktProgressRepository { ) } .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + log.d { + "fetchWatchedShowSeedEntries mapped=${mapped.size} useFurthest=$useFurthestEpisode " + + "items=${mapped.debugWatchProgressSummary()}" + } + mapped } private fun mergeNewestByVideoId(entries: List): List { @@ -436,6 +529,8 @@ object TraktProgressRepository { private fun invalidateInFlightRefreshes() { refreshRequestId += 1L + inFlightRefresh?.cancel() + inFlightRefresh = null } private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId @@ -819,3 +914,165 @@ private data class TraktEpisode( @SerialName("number") val number: Int? = null, @SerialName("ids") val ids: TraktExternalIds? = null, ) + +private fun WatchProgressEntry.debugSummary(): String = + buildString { + append(parentMetaType) + append(":") + append(parentMetaId) + if (seasonNumber != null || episodeNumber != null) { + append(" s=") + append(seasonNumber) + append(" e=") + append(episodeNumber) + } + append(" video=") + append(videoId) + append(" pct=") + append(progressPercent) + append(" completed=") + append(isCompleted) + append(" effectiveCompleted=") + append(isEffectivelyCompleted) + append(" src=") + append(source) + append(" last=") + append(lastUpdatedEpochMs) + } + +private fun Collection.debugWatchProgressSummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } + +private fun Collection.debugSourceCounts(): String = + groupingBy { it.source } + .eachCount() + .entries + .sortedBy { it.key } + .joinToString(separator = ",") { "${it.key}=${it.value}" } + .ifBlank { "none" } + +private fun Collection.debugPlaybackSummary(limit: Int = 8): String = + take(limit).joinToString(separator = " | ") { item -> + val media = item.movie ?: item.show + val episode = item.episode + buildString { + append(media?.title ?: "unknown") + append(" ids=") + append(media?.ids.debugIds()) + if (episode != null) { + append(" ep=") + append(episode.season) + append("x") + append(episode.number) + append(" epIds=") + append(episode.ids.debugIds()) + } + append(" progress=") + append(item.progress) + append(" pausedAt=") + append(item.pausedAt) + append(" playbackId=") + append(item.id) + } + }.ifBlank { "none" } + +private fun Collection.debugHistoryEpisodeSummary(limit: Int = 8): String = + take(limit).joinToString(separator = " | ") { item -> + buildString { + append(item.show?.title ?: "unknown") + append(" ids=") + append(item.show?.ids.debugIds()) + append(" ep=") + append(item.episode?.season) + append("x") + append(item.episode?.number) + append(" epIds=") + append(item.episode?.ids.debugIds()) + append(" watchedAt=") + append(item.watchedAt) + } + }.ifBlank { "none" } + +private fun Collection.debugHistoryMovieSummary(limit: Int = 8): String = + take(limit).joinToString(separator = " | ") { item -> + buildString { + append(item.movie?.title ?: "unknown") + append(" ids=") + append(item.movie?.ids.debugIds()) + append(" watchedAt=") + append(item.watchedAt) + } + }.ifBlank { "none" } + +private fun Collection.debugWatchedShowSummary(limit: Int = 8): String = + take(limit).joinToString(separator = " | ") { item -> + val episodeCount = item.seasons.orEmpty().sumOf { season -> + season.episodes.orEmpty().count { episode -> + (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 + } + } + val latest = item.seasons.orEmpty() + .flatMap { season -> + val seasonNumber = season.number + season.episodes.orEmpty().mapNotNull { episode -> + val episodeNumber = episode.number ?: return@mapNotNull null + val watchedAt = episode.lastWatchedAt ?: item.lastWatchedAt + TraktWatchedShowEpisodeSeed( + season = seasonNumber ?: 0, + episode = episodeNumber, + watchedAt = watchedAt + ?.let { value -> + runCatching { TraktPlatformClock.parseIsoDateTimeToEpochMs(value) }.getOrNull() + } + ?: 0L, + ) + } + } + .maxWithOrNull( + compareBy( + { it.watchedAt }, + { it.season }, + { it.episode }, + ), + ) + buildString { + append(item.show?.title ?: "unknown") + append(" ids=") + append(item.show?.ids.debugIds()) + append(" episodes=") + append(episodeCount) + append(" latest=") + append(latest?.season) + append("x") + append(latest?.episode) + append(" lastWatchedAt=") + append(item.lastWatchedAt) + } + }.ifBlank { "none" } + +private fun TraktExternalIds?.debugIds(): String = + if (this == null) { + "none" + } else { + buildString { + imdb?.takeIf { it.isNotBlank() }?.let { + append("imdb:") + append(it) + } + tmdb?.let { + if (isNotEmpty()) append(",") + append("tmdb:") + append(it) + } + trakt?.let { + if (isNotEmpty()) append(",") + append("trakt:") + append(it) + } + slug?.takeIf { it.isNotBlank() }?.let { + if (isNotEmpty()) append(",") + append("slug:") + append(it) + } + }.ifBlank { "none" } + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index c1099d50..80146409 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.watching.sync +import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.watchprogress.WatchProgressEntry import io.github.jan.supabase.postgrest.postgrest @@ -12,12 +13,14 @@ import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put object SupabaseProgressSyncAdapter : ProgressSyncAdapter { + private val log = Logger.withTag("NuvioSyncProgress") private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } override suspend fun pull(profileId: Int): List { + log.d { "pull start profileId=$profileId" } val params = buildJsonObject { put("p_profile_id", profileId) } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val serverEntries = result.decodeList() @@ -33,6 +36,10 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { lastWatched = entry.lastWatched, ) } + log.d { + "pull returned raw=${serverEntries.size} records=${records.size} " + + "items=${records.debugProgressRecordSummary()}" + } return records } @@ -40,6 +47,10 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { profileId: Int, entries: Collection, ) { + log.d { + "push start profileId=$profileId entries=${entries.size} " + + "items=${entries.debugWatchProgressEntrySummary()}" + } val syncEntries = entries.map { entry -> WatchProgressSyncEntry( contentId = entry.parentMetaId, @@ -58,12 +69,17 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { put("p_entries", json.encodeToJsonElement(syncEntries)) } SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params) + log.d { "push complete profileId=$profileId entries=${syncEntries.size}" } } override suspend fun delete( profileId: Int, entries: Collection, ) { + log.d { + "delete start profileId=$profileId entries=${entries.size} " + + "items=${entries.debugWatchProgressEntrySummary()}" + } val progressKeys = entries.map { entry -> if (entry.seasonNumber != null && entry.episodeNumber != null) { "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" @@ -76,6 +92,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { put("p_keys", json.encodeToJsonElement(progressKeys)) } SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) + log.d { "delete complete profileId=$profileId keys=${progressKeys.joinToString(limit = 12)}" } } private fun progressKeyForEntry(entry: WatchProgressEntry): String = @@ -98,3 +115,53 @@ private data class WatchProgressSyncEntry( @SerialName("last_watched") val lastWatched: Long = 0, @SerialName("progress_key") val progressKey: String = "", ) + +private fun Collection.debugProgressRecordSummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { record -> + buildString { + append(record.contentType) + append(":") + append(record.contentId) + if (record.season != null || record.episode != null) { + append(" s=") + append(record.season) + append(" e=") + append(record.episode) + } + append(" video=") + append(record.videoId) + append(" pos=") + append(record.position) + append(" dur=") + append(record.duration) + append(" last=") + append(record.lastWatched) + } + }.ifBlank { "none" } + +private fun Collection.debugWatchProgressEntrySummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { entry -> + buildString { + append(entry.parentMetaType) + append(":") + append(entry.parentMetaId) + if (entry.seasonNumber != null || entry.episodeNumber != null) { + append(" s=") + append(entry.seasonNumber) + append(" e=") + append(entry.episodeNumber) + } + append(" video=") + append(entry.videoId) + append(" pos=") + append(entry.lastPositionMs) + append(" dur=") + append(entry.durationMs) + append(" pct=") + append(entry.progressPercent) + append(" completed=") + append(entry.isCompleted) + append(" last=") + append(entry.lastUpdatedEpochMs) + } + }.ifBlank { "none" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 0485986b..9fb84629 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -118,6 +118,7 @@ data class WatchProgressEntry( data class WatchProgressUiState( val entries: List = emptyList(), + val hasLoadedRemoteProgress: Boolean = false, ) { val byVideoId: Map get() = entries.associateBy { it.videoId } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index ebdb27d5..741bfd00 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot @@ -11,11 +13,14 @@ import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressSyncAdapter +import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,6 +28,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("WatchProgressRepository") @@ -34,6 +41,8 @@ object WatchProgressRepository { private var currentProfileId: Int = 1 private var entriesByVideoId: MutableMap = mutableMapOf() private var metadataResolutionJob: Job? = null + private var isPullingNuvioSyncFromServer = false + private var hasCompletedInitialNuvioSyncPull = false internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { @@ -45,7 +54,10 @@ object WatchProgressRepository { ) ) { runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } + } } publish() } @@ -59,7 +71,10 @@ object WatchProgressRepository { ) ) { runCatching { TraktProgressRepository.refreshNow() } - .onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } + } } publish() } @@ -72,6 +87,35 @@ object WatchProgressRepository { } } } + + syncScope.launch { + while (true) { + delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS) + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + if (shouldUseTraktProgress()) continue + + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) continue + if (!hasCompletedInitialNuvioSyncPull || isPullingNuvioSyncFromServer) continue + + log.d { + "periodic NuvioSync pull start profileId=${ProfileRepository.activeProfileId} " + + "entries=${entriesByVideoId.size}" + } + runCatching { pullFromServer(ProfileRepository.activeProfileId) } + .onSuccess { + log.d { + "periodic NuvioSync pull complete profileId=${ProfileRepository.activeProfileId} " + + "entries=${entriesByVideoId.size}" + } + } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Periodic NuvioSync pull failed: ${error.message}" } + } + } + } } fun ensureLoaded() { @@ -127,57 +171,93 @@ object WatchProgressRepository { currentProfileId = profileId val useTraktProgress = shouldUseTraktProgress() - - if (useTraktProgress) { - runCatching { TraktProgressRepository.refreshNow() } - .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } - publish() - return + log.d { + "pullFromServer start profileId=$profileId source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " + + "localEntries=${entriesByVideoId.size}" } - runCatching { - val serverEntries = syncAdapter.pull(profileId = profileId) + if (!useTraktProgress && isPullingNuvioSyncFromServer) { + log.d { "pullFromServer NuvioSync skipped: pull already in flight profileId=$profileId" } + return + } + if (!useTraktProgress) { + isPullingNuvioSyncFromServer = true + } - val oldLocal = entriesByVideoId.toMap() - val newMap = mutableMapOf() - - serverEntries.forEach { entry -> - val videoId = entry.videoId - val cached = oldLocal[videoId] - newMap[videoId] = WatchProgressEntry( - contentType = entry.contentType, - parentMetaId = entry.contentId, - parentMetaType = cached?.parentMetaType ?: entry.contentType, - videoId = videoId, - title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId, - logo = cached?.logo, - poster = cached?.poster, - background = cached?.background, - seasonNumber = entry.season, - episodeNumber = entry.episode, - episodeTitle = cached?.episodeTitle, - episodeThumbnail = cached?.episodeThumbnail, - lastPositionMs = entry.position, - durationMs = entry.duration, - lastUpdatedEpochMs = entry.lastWatched, - providerName = cached?.providerName, - providerAddonId = cached?.providerAddonId, - lastStreamTitle = cached?.lastStreamTitle, - lastStreamSubtitle = cached?.lastStreamSubtitle, - pauseDescription = cached?.pauseDescription, - lastSourceUrl = cached?.lastSourceUrl, - isCompleted = isWatchProgressComplete(entry.position, entry.duration, false), - ) + try { + if (useTraktProgress) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { e -> + if (e is CancellationException) throw e + log.e(e) { "Failed to pull Trakt progress" } + } + publish() + log.d { + "pullFromServer trakt complete entries=${TraktProgressRepository.uiState.value.entries.size} " + + "sources=${TraktProgressRepository.uiState.value.entries.debugSourceCounts()} " + + "items=${TraktProgressRepository.uiState.value.entries.debugWatchProgressEntrySummary()}" + } + return } - entriesByVideoId = newMap - hasLoaded = true - publish() - persist() + runCatching { + val serverEntries = syncAdapter.pull(profileId = profileId) + log.d { + "pullFromServer NuvioSync returned ${serverEntries.size} records " + + "items=${serverEntries.debugProgressRecordSummary()}" + } - resolveRemoteMetadata() - }.onFailure { e -> - log.e(e) { "Failed to pull watch progress from server" } + val oldLocal = entriesByVideoId.toMap() + val newMap = mutableMapOf() + + serverEntries.forEach { entry -> + val videoId = entry.videoId + val cached = oldLocal[videoId] + newMap[videoId] = WatchProgressEntry( + contentType = entry.contentType, + parentMetaId = entry.contentId, + parentMetaType = cached?.parentMetaType ?: entry.contentType, + videoId = videoId, + title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId, + logo = cached?.logo, + poster = cached?.poster, + background = cached?.background, + seasonNumber = entry.season, + episodeNumber = entry.episode, + episodeTitle = cached?.episodeTitle, + episodeThumbnail = cached?.episodeThumbnail, + lastPositionMs = entry.position, + durationMs = entry.duration, + lastUpdatedEpochMs = entry.lastWatched, + providerName = cached?.providerName, + providerAddonId = cached?.providerAddonId, + lastStreamTitle = cached?.lastStreamTitle, + lastStreamSubtitle = cached?.lastStreamSubtitle, + pauseDescription = cached?.pauseDescription, + lastSourceUrl = cached?.lastSourceUrl, + isCompleted = isWatchProgressComplete(entry.position, entry.duration, false), + ) + } + + entriesByVideoId = newMap + hasLoaded = true + hasCompletedInitialNuvioSyncPull = true + publish() + persist() + log.d { + "pullFromServer NuvioSync applied entries=${entriesByVideoId.size} " + + "items=${entriesByVideoId.values.debugWatchProgressEntrySummary()}" + } + + resolveRemoteMetadata() + }.onFailure { e -> + if (e is CancellationException) throw e + log.e(e) { "Failed to pull watch progress from server" } + } + } finally { + if (!useTraktProgress) { + isPullingNuvioSyncFromServer = false + } } } @@ -186,7 +266,15 @@ object WatchProgressRepository { .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } .groupBy { it.parentMetaId to it.contentType } - if (needsResolution.isEmpty()) return + if (needsResolution.isEmpty()) { + log.d { "resolveRemoteMetadata skipped: all entries have artwork" } + return + } + log.d { + "resolveRemoteMetadata start groups=${needsResolution.size} " + + "entries=${needsResolution.values.sumOf { it.size }} " + + "keys=${needsResolution.keys.joinToString(limit = 12) { (metaId, type) -> "$type:$metaId" }}" + } metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch { @@ -201,7 +289,11 @@ object WatchProgressRepository { val (metaId, metaType) = key val meta = runCatching { MetaDetailsRepository.fetch(metaType, metaId) - }.getOrNull() ?: continue + }.getOrNull() + if (meta == null) { + log.d { "resolveRemoteMetadata miss type=$metaType id=$metaId entries=${entries.size}" } + continue + } for (entry in entries) { val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) { @@ -224,8 +316,13 @@ object WatchProgressRepository { } publish() + log.d { + "resolveRemoteMetadata applied type=$metaType id=$metaId entries=${entries.size} " + + "metaVideos=${meta.videos.size}" + } } persist() + log.d { "resolveRemoteMetadata complete entries=${entriesByVideoId.size}" } } } @@ -350,6 +447,10 @@ object WatchProgressRepository { isEnded = snapshot.isEnded, ) if (!isCompleted && !shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) { + log.d { + "upsert skipped below threshold video=${session.videoId} content=${session.parentMetaId} " + + "s=${session.seasonNumber} e=${session.episodeNumber} pos=$positionMs dur=$durationMs ended=${snapshot.isEnded}" + } return } @@ -383,6 +484,10 @@ object WatchProgressRepository { } val useTraktProgress = shouldUseTraktProgress() + log.d { + "upsert progress source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " + + "entry=${entry.debugSummary()} snapshotEnded=${snapshot.isEnded}" + } entriesByVideoId[session.videoId] = entry if (useTraktProgress) { @@ -403,7 +508,9 @@ object WatchProgressRepository { syncScope.launch { runCatching { val profileId = ProfileRepository.activeProfileId + log.d { "pushScrobbleToServer profileId=$profileId entry=${entry.debugSummary()}" } syncAdapter.push(profileId = profileId, entries = listOf(entry)) + log.d { "pushScrobbleToServer complete profileId=$profileId video=${entry.videoId}" } }.onFailure { e -> log.e(e) { "Failed to push watch progress scrobble" } } @@ -416,7 +523,12 @@ object WatchProgressRepository { runCatching { if (entries.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId + log.d { + "pushDeleteToServer profileId=$profileId entries=${entries.size} " + + "items=${entries.debugWatchProgressEntrySummary()}" + } syncAdapter.delete(profileId = profileId, entries = entries) + log.d { "pushDeleteToServer complete profileId=$profileId entries=${entries.size}" } }.onFailure { e -> log.e(e) { "Failed to push watch progress delete" } } @@ -426,8 +538,18 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } + log.d { + "publish source=${if (shouldUseTraktProgress()) "trakt" else "nuvio_sync"} " + + "entries=${sortedEntries.size} cw=${sortedEntries.continueWatchingEntries().size} " + + "sources=${sortedEntries.debugSourceCounts()} items=${sortedEntries.debugWatchProgressEntrySummary()}" + } _uiState.value = WatchProgressUiState( entries = sortedEntries, + hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { + TraktProgressRepository.uiState.value.hasLoadedRemoteProgress + } else { + hasLoaded + }, ) } @@ -453,3 +575,67 @@ object WatchProgressRepository { } } + +private fun ProgressSyncRecord.debugSummary(): String = + buildString { + append(contentType) + append(":") + append(contentId) + if (season != null || episode != null) { + append(" s=") + append(season) + append(" e=") + append(episode) + } + append(" video=") + append(videoId) + append(" pos=") + append(position) + append(" dur=") + append(duration) + append(" last=") + append(lastWatched) + } + +private fun Collection.debugProgressRecordSummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } + +private fun WatchProgressEntry.debugSummary(): String = + buildString { + append(parentMetaType) + append(":") + append(parentMetaId) + if (seasonNumber != null || episodeNumber != null) { + append(" s=") + append(seasonNumber) + append(" e=") + append(episodeNumber) + } + append(" video=") + append(videoId) + append(" pos=") + append(lastPositionMs) + append(" dur=") + append(durationMs) + append(" pct=") + append(progressPercent) + append(" completed=") + append(isCompleted) + append(" effectiveCompleted=") + append(isEffectivelyCompleted) + append(" src=") + append(source) + append(" last=") + append(lastUpdatedEpochMs) + } + +private fun Collection.debugWatchProgressEntrySummary(limit: Int = 10): String = + take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } + +private fun Collection.debugSourceCounts(): String = + groupingBy { it.source } + .eachCount() + .entries + .sortedBy { it.key } + .joinToString(separator = ",") { "${it.key}=${it.value}" } + .ifBlank { "none" } From 8464f4db488fcde0490f6f17f870a7e48104adc5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 01:13:39 +0530 Subject: [PATCH 05/19] feat: state managing for cw cards --- .../components/HomeContinueWatchingSection.kt | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index c200b6af..6037ce7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -159,37 +160,60 @@ private fun HomeContinueWatchingSectionContent( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() - NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), - entries = items, - modifier = modifier, - headerHorizontalPadding = sectionPadding, - rowContentPadding = PaddingValues(horizontal = sectionPadding), - itemSpacing = layout.itemGap, - showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, - key = { item -> item.videoId }, - ) { item -> - when (style) { - ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) - ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( - item = item, - layout = layout, - useEpisodeThumbnails = useEpisodeThumbnails, - blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, - ) + val itemOrderKey = remember(items) { + items.joinToString(separator = "|") { item -> item.continueWatchingRowOrderKey() } + } + + key(itemOrderKey) { + NuvioShelfSection( + title = stringResource(Res.string.compose_settings_page_continue_watching), + entries = items, + modifier = modifier, + headerHorizontalPadding = sectionPadding, + rowContentPadding = PaddingValues(horizontal = sectionPadding), + itemSpacing = layout.itemGap, + showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, + key = { item -> item.videoId }, + ) { item -> + when (style) { + ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( + item = item, + layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) + } } } } +private fun ContinueWatchingItem.continueWatchingRowOrderKey(): String = + buildString { + append(if (isNextUp) "next" else "progress") + append(':') + append(parentMetaId) + append(':') + append(videoId) + append(':') + append(seasonNumber) + append('x') + append(episodeNumber) + append(":seed=") + append(nextUpSeedSeasonNumber) + append('x') + append(nextUpSeedEpisodeNumber) + } + @Composable fun ContinueWatchingStylePreview( style: ContinueWatchingSectionStyle, From 5aee64e25e461f22909ee2c7f7be872d0031b162 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 01:32:11 +0530 Subject: [PATCH 06/19] ref: cache and log cleanup --- .../com/nuvio/app/features/home/HomeScreen.kt | 289 ++++++------------ .../features/trakt/TraktProgressRepository.kt | 214 ------------- .../sync/SupabaseProgressSyncAdapter.kt | 70 +---- .../ContinueWatchingEnrichmentCache.kt | 13 +- .../watchprogress/WatchProgressRepository.kt | 125 -------- 5 files changed, 103 insertions(+), 608 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 5df53b1c..3bf4715b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -356,43 +355,6 @@ fun HomeScreen( todayIsoDate = CurrentDateProvider.todayIsoDate(), ) } - LaunchedEffect( - isTraktProgressActive, - traktSettingsUiState.continueWatchingDaysCap, - watchProgressUiState.hasLoadedRemoteProgress, - continueWatchingPreferences.upNextFromFurthestEpisode, - watchProgressUiState.entries, - effectiveWatchProgressEntries, - allNextUpSeedEntries, - recentNextUpSeedEntries, - nextUpSuppressedSeriesIds, - visibleContinueWatchingEntries, - completedSeriesCandidates, - cachedInProgressItems, - cachedNextUpItems, - nextUpItemsBySeries, - processedNextUpContentIds, - effectivNextUpItems, - continueWatchingItems, - ) { - homeCwLog.d { - "build summary source=${if (isTraktProgressActive) "trakt" else "nuvio_sync"} " + - "remoteLoaded=${watchProgressUiState.hasLoadedRemoteProgress} " + - "daysCap=${traktSettingsUiState.continueWatchingDaysCap} " + - "raw=${watchProgressUiState.entries.size} rawSources=${watchProgressUiState.entries.debugSourceCounts()} " + - "effective=${effectiveWatchProgressEntries.size} seedAll=${allNextUpSeedEntries.size} " + - "seedRecent=${recentNextUpSeedEntries.size} seedSuppressed=${nextUpSuppressedSeriesIds.size} " + - "useFurthest=${continueWatchingPreferences.upNextFromFurthestEpisode} " + - "visibleInProgress=${visibleContinueWatchingEntries.size} " + - "completedCandidates=${completedSeriesCandidates.size} cachedInProgress=${cachedInProgressItems.size} " + - "cachedNextUp=${cachedNextUpItems.size} liveNextUp=${nextUpItemsBySeries.size} " + - "processedNextUp=${processedNextUpContentIds.size} " + - "effectiveNextUp=${effectivNextUpItems.size} final=${continueWatchingItems.size} " + - "rawItems=${watchProgressUiState.entries.debugWatchProgressSummary()} " + - "completed=${completedSeriesCandidates.debugCompletedSeriesSummary()} " + - "finalItems=${continueWatchingItems.debugContinueWatchingSummary()}" - } - } val availableManifests = remember(addonsUiState.addons) { addonsUiState.addons.mapNotNull { addon -> addon.manifest } } @@ -430,35 +392,51 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, + cachedNextUpItems, + visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, ) { if (completedSeriesCandidates.isEmpty()) { - homeCwLog.d { - "next-up resolve skipped: no completed series candidates " + - "entries=${effectiveWatchProgressEntries.size} sources=${effectiveWatchProgressEntries.debugSourceCounts()}" - } nextUpItemsBySeries = emptyMap() processedNextUpContentIds = emptySet() return@LaunchedEffect } - if (metaProviderKey.isEmpty()) { - homeCwLog.d { - "next-up resolve deferred: no meta providers candidates=${completedSeriesCandidates.size} " + - "candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}" + val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> + val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null + val item = cached.second + if ( + item.nextUpSeedSeasonNumber != candidate.seasonNumber || + item.nextUpSeedEpisodeNumber != candidate.episodeNumber + ) { + return@mapNotNull null } + candidate.content.id to cached + }.toMap() + val candidatesToResolve = completedSeriesCandidates.filter { candidate -> + candidate.content.id !in cachedResolvedNextUpItems + } + if (candidatesToResolve.isEmpty()) { + nextUpItemsBySeries = cachedResolvedNextUpItems + processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> + candidate.content.id + } + saveContinueWatchingSnapshots( + nextUpItemsBySeries = cachedResolvedNextUpItems, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = CurrentDateProvider.todayIsoDate(), + ) + return@LaunchedEffect + } + + if (metaProviderKey.isEmpty()) { return@LaunchedEffect } val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(4) - homeCwLog.d { - "next-up resolve start candidates=${completedSeriesCandidates.size} " + - "showUnaired=${continueWatchingPreferences.showUnairedNextUp} " + - "metaProviders=${metaProviderKey.size} candidates=${completedSeriesCandidates.debugCompletedSeriesSummary()}" - } - val results = completedSeriesCandidates.map { completedEntry -> + val freshResults = candidatesToResolve.map { completedEntry -> async { semaphore.withPermit { val meta = MetaDetailsRepository.fetch( @@ -466,10 +444,6 @@ fun HomeScreen( id = completedEntry.content.id, ) if (meta == null) { - homeCwLog.d { - "next-up meta miss content=${completedEntry.debugSummary()} " + - "type=${completedEntry.content.type} id=${completedEntry.content.id}" - } return@withPermit null } val nextEpisode = meta.nextReleasedEpisodeAfter( @@ -479,84 +453,28 @@ fun HomeScreen( showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) if (nextEpisode == null) { - homeCwLog.d { - "next-up no next episode content=${completedEntry.debugSummary()} " + - "videos=${meta.videos.size}" - } return@withPermit null } val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) { - homeCwLog.d { "next-up dismissed item=${item.debugSummary()}" } return@withPermit null } - homeCwLog.d { - "next-up built seed=${completedEntry.debugSummary()} item=${item.debugSummary()} " + - "released=${nextEpisode.released}" - } completedEntry.content.id to (completedEntry.markedAtEpochMs to item) } } }.awaitAll().filterNotNull().toMap() + val results = cachedResolvedNextUpItems + freshResults nextUpItemsBySeries = results processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } - val nextUpCache = results.mapNotNull { (contentId, pair) -> - val item = pair.second - CachedNextUpItem( - contentId = contentId, - contentType = item.parentMetaType, - name = item.title, - poster = item.poster, - backdrop = item.background, - logo = item.logo, - videoId = item.videoId, - season = item.seasonNumber, - episode = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - pauseDescription = item.pauseDescription, - released = item.released, - hasAired = item.released?.let { released -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) - } ?: true, - lastWatched = pair.first, - sortTimestamp = pair.first, - seedSeason = item.nextUpSeedSeasonNumber, - seedEpisode = item.nextUpSeedEpisodeNumber, - ) - } - val inProgressCache = visibleContinueWatchingEntries.map { entry -> - CachedInProgressItem( - contentId = entry.parentMetaId, - contentType = entry.contentType, - name = entry.title, - poster = entry.poster, - backdrop = entry.background, - logo = entry.logo, - videoId = entry.videoId, - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - pauseDescription = entry.pauseDescription, - position = entry.lastPositionMs, - duration = entry.durationMs, - lastWatched = entry.lastUpdatedEpochMs, - progressPercent = entry.progressPercent, - ) - } - ContinueWatchingEnrichmentCache.saveSnapshots( - nextUp = nextUpCache, - inProgress = inProgressCache, + saveContinueWatchingSnapshots( + nextUpItemsBySeries = results, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, ) - homeCwLog.d { - "next-up resolve complete results=${results.size} nextUpCache=${nextUpCache.size} " + - "inProgressCache=${inProgressCache.size} items=${results.values.map { it.second }.debugContinueWatchingSummary()}" - } } val hasActiveAddons = addonsUiState.addons.any { it.manifest != null } @@ -784,7 +702,6 @@ fun HomeScreen( private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L -private val homeCwLog = Logger.withTag("HomeCW") internal fun filterEntriesForTraktContinueWatchingWindow( entries: List, @@ -1090,6 +1007,62 @@ private data class HomeContinueWatchingCandidate( val isProgressEntry: Boolean, ) +private fun saveContinueWatchingSnapshots( + nextUpItemsBySeries: Map>, + visibleContinueWatchingEntries: List, + todayIsoDate: String, +) { + val nextUpCache = nextUpItemsBySeries.mapNotNull { (contentId, pair) -> + val item = pair.second + CachedNextUpItem( + contentId = contentId, + contentType = item.parentMetaType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = item.videoId, + season = item.seasonNumber, + episode = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + released = item.released, + hasAired = item.released?.let { released -> + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) + } ?: true, + lastWatched = pair.first, + sortTimestamp = pair.first, + seedSeason = item.nextUpSeedSeasonNumber, + seedEpisode = item.nextUpSeedEpisodeNumber, + ) + } + val inProgressCache = visibleContinueWatchingEntries.map { entry -> + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = entry.title, + poster = entry.poster, + backdrop = entry.background, + logo = entry.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + pauseDescription = entry.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + ) + } + ContinueWatchingEnrichmentCache.saveSnapshots( + nextUp = nextUpCache, + inProgress = inProgressCache, + ) +} + private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) = WatchProgressEntry( contentType = content.type, @@ -1201,83 +1174,3 @@ private fun ContinueWatchingItem.withFallbackMetadata( released = released ?: fallback.released, ) } - -private fun WatchProgressEntry.debugSummary(): String = - buildString { - append(parentMetaType) - append(":") - append(parentMetaId) - if (seasonNumber != null || episodeNumber != null) { - append(" s=") - append(seasonNumber) - append(" e=") - append(episodeNumber) - } - append(" video=") - append(videoId) - append(" pct=") - append(progressPercent) - append(" completed=") - append(isCompleted) - append(" effectiveCompleted=") - append(isEffectivelyCompleted) - append(" src=") - append(source) - append(" last=") - append(lastUpdatedEpochMs) - } - -private fun Collection.debugWatchProgressSummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } - -private fun Collection.debugSourceCounts(): String = - groupingBy { it.source } - .eachCount() - .entries - .sortedBy { it.key } - .joinToString(separator = ",") { "${it.key}=${it.value}" } - .ifBlank { "none" } - -private fun CompletedSeriesCandidate.debugSummary(): String = - buildString { - append(content.type) - append(":") - append(content.id) - append(" s=") - append(seasonNumber) - append(" e=") - append(episodeNumber) - append(" marked=") - append(markedAtEpochMs) - } - -private fun Collection.debugCompletedSeriesSummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } - -private fun ContinueWatchingItem.debugSummary(): String = - buildString { - append(if (isNextUp) "next_up" else "in_progress") - append(":") - append(parentMetaType) - append(":") - append(parentMetaId) - if (seasonNumber != null || episodeNumber != null) { - append(" s=") - append(seasonNumber) - append(" e=") - append(episodeNumber) - } - append(" video=") - append(videoId) - append(" seed=") - append(nextUpSeedSeasonNumber) - append("x") - append(nextUpSeedEpisodeNumber) - append(" progress=") - append(progressFraction) - append(" resume=") - append(resumePositionMs) - } - -private fun Collection.debugContinueWatchingSummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 6d9f99ee..e5361ea2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -111,12 +111,10 @@ object TraktProgressRepository { val requestId = nextRefreshRequestId() val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { - log.d { "refreshNow request=$requestId skipped: missing authorized headers" } _uiState.value = TraktProgressUiState() return } - log.d { "refreshNow request=$requestId start currentEntries=${_uiState.value.entries.size}" } _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) val playbackEntries = runCatching { @@ -140,10 +138,6 @@ object TraktProgressRepository { errorMessage = null, hasLoadedRemoteProgress = false, ) - log.d { - "refreshNow request=$requestId playback applied entries=${playbackEntries.size} " + - "sources=${playbackEntries.debugSourceCounts()} items=${playbackEntries.debugWatchProgressSummary()}" - } if (playbackEntries.isNotEmpty()) { launchHydration(requestId = requestId, entries = playbackEntries) @@ -178,11 +172,6 @@ object TraktProgressRepository { errorMessage = null, hasLoadedRemoteProgress = true, ) - log.d { - "refreshNow request=$requestId completed snapshot applied " + - "completedEntries=${completedEntries.size} merged=${merged.size} " + - "sources=${merged.debugSourceCounts()} items=${merged.debugWatchProgressSummary()}" - } if (merged.isNotEmpty()) { launchHydration(requestId = requestId, entries = merged) @@ -212,10 +201,6 @@ object TraktProgressRepository { isLoading = false, errorMessage = null, ) - log.d { - "hydrate request=$requestId applied hydrated=${hydrated.size} merged=${merged.size} " + - "items=${merged.debugWatchProgressSummary()}" - } } } @@ -228,10 +213,6 @@ object TraktProgressRepository { current[normalizedEntry.videoId] = normalizedEntry } _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) - log.d { - "optimistic progress applied entry=${normalizedEntry.debugSummary()} " + - "entries=${_uiState.value.entries.size}" - } } fun applyOptimisticRemoval(videoId: String) { @@ -239,7 +220,6 @@ object TraktProgressRepository { if (videoId.isBlank()) return val filtered = _uiState.value.entries.filterNot { it.videoId == videoId } _uiState.value = _uiState.value.copy(entries = filtered) - log.d { "optimistic removal videoId=$videoId entries=${filtered.size}" } } fun applyOptimisticRemoval( @@ -260,10 +240,6 @@ object TraktProgressRepository { } } _uiState.value = _uiState.value.copy(entries = filtered) - log.d { - "optimistic removal contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber " + - "entries=${filtered.size}" - } } suspend fun removeProgress( @@ -275,7 +251,6 @@ object TraktProgressRepository { if (normalizedContentId.isBlank()) return val headers = TraktAuthRepository.authorizedHeaders() ?: return - log.d { "removeProgress start contentId=$normalizedContentId s=$seasonNumber e=$episodeNumber" } applyOptimisticRemoval( contentId = normalizedContentId, seasonNumber = seasonNumber, @@ -343,12 +318,10 @@ object TraktProgressRepository { } } - log.d { "removeProgress complete contentId=$normalizedContentId refreshing" } refreshNow() } private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { - log.d { "fetchPlaybackEntries start" } val payloads = coroutineScope { val moviesPayload = async { httpGetTextWithHeaders( @@ -371,10 +344,6 @@ object TraktProgressRepository { val moviePlayback = json.decodeFromString>(moviesPayload) val episodePlayback = json.decodeFromString>(episodesPayload) - log.d { - "fetchPlaybackEntries raw movies=${moviePlayback.size} episodes=${episodePlayback.size} " + - "movieItems=${moviePlayback.debugPlaybackSummary()} episodeItems=${episodePlayback.debugPlaybackSummary()}" - } val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item -> mapPlaybackMovie(item = item, fallbackIndex = index) @@ -384,15 +353,10 @@ object TraktProgressRepository { } val merged = mergeNewestByVideoId(inProgressMovies + inProgressEpisodes) - log.d { - "fetchPlaybackEntries mapped movies=${inProgressMovies.size} episodes=${inProgressEpisodes.size} " + - "merged=${merged.size} items=${merged.debugWatchProgressSummary()}" - } merged } private suspend fun fetchHistoryEntries(headers: Map): List = withContext(Dispatchers.Default) { - log.d { "fetchHistoryEntries start limit=$HISTORY_LIMIT" } val payloads = coroutineScope { val historyPayload = async { httpGetTextWithHeaders( @@ -414,10 +378,6 @@ object TraktProgressRepository { val movieHistoryPayload = payloads[1] val episodeHistory = json.decodeFromString>(historyPayload) val movieHistory = json.decodeFromString>(movieHistoryPayload) - log.d { - "fetchHistoryEntries raw episodes=${episodeHistory.size} movies=${movieHistory.size} " + - "episodeItems=${episodeHistory.debugHistoryEpisodeSummary()} movieItems=${movieHistory.debugHistoryMovieSummary()}" - } val completedEpisodes = episodeHistory .mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) } @@ -427,10 +387,6 @@ object TraktProgressRepository { .distinctBy { entry -> entry.videoId } val merged = mergeNewestByVideoId(completedEpisodes + completedMovies) - log.d { - "fetchHistoryEntries mapped episodes=${completedEpisodes.size} movies=${completedMovies.size} " + - "merged=${merged.size} items=${merged.debugWatchProgressSummary()}" - } merged } @@ -444,10 +400,6 @@ object TraktProgressRepository { headers = headers, ) val watchedShows = json.decodeFromString>(payload) - log.d { - "fetchWatchedShowSeedEntries raw shows=${watchedShows.size} " + - "items=${watchedShows.debugWatchedShowSummary()}" - } val mapped = watchedShows .mapNotNull { item -> mapWatchedShowSeed( @@ -456,10 +408,6 @@ object TraktProgressRepository { ) } .sortedByDescending { entry -> entry.lastUpdatedEpochMs } - log.d { - "fetchWatchedShowSeedEntries mapped=${mapped.size} useFurthest=$useFurthestEpisode " + - "items=${mapped.debugWatchProgressSummary()}" - } mapped } @@ -914,165 +862,3 @@ private data class TraktEpisode( @SerialName("number") val number: Int? = null, @SerialName("ids") val ids: TraktExternalIds? = null, ) - -private fun WatchProgressEntry.debugSummary(): String = - buildString { - append(parentMetaType) - append(":") - append(parentMetaId) - if (seasonNumber != null || episodeNumber != null) { - append(" s=") - append(seasonNumber) - append(" e=") - append(episodeNumber) - } - append(" video=") - append(videoId) - append(" pct=") - append(progressPercent) - append(" completed=") - append(isCompleted) - append(" effectiveCompleted=") - append(isEffectivelyCompleted) - append(" src=") - append(source) - append(" last=") - append(lastUpdatedEpochMs) - } - -private fun Collection.debugWatchProgressSummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } - -private fun Collection.debugSourceCounts(): String = - groupingBy { it.source } - .eachCount() - .entries - .sortedBy { it.key } - .joinToString(separator = ",") { "${it.key}=${it.value}" } - .ifBlank { "none" } - -private fun Collection.debugPlaybackSummary(limit: Int = 8): String = - take(limit).joinToString(separator = " | ") { item -> - val media = item.movie ?: item.show - val episode = item.episode - buildString { - append(media?.title ?: "unknown") - append(" ids=") - append(media?.ids.debugIds()) - if (episode != null) { - append(" ep=") - append(episode.season) - append("x") - append(episode.number) - append(" epIds=") - append(episode.ids.debugIds()) - } - append(" progress=") - append(item.progress) - append(" pausedAt=") - append(item.pausedAt) - append(" playbackId=") - append(item.id) - } - }.ifBlank { "none" } - -private fun Collection.debugHistoryEpisodeSummary(limit: Int = 8): String = - take(limit).joinToString(separator = " | ") { item -> - buildString { - append(item.show?.title ?: "unknown") - append(" ids=") - append(item.show?.ids.debugIds()) - append(" ep=") - append(item.episode?.season) - append("x") - append(item.episode?.number) - append(" epIds=") - append(item.episode?.ids.debugIds()) - append(" watchedAt=") - append(item.watchedAt) - } - }.ifBlank { "none" } - -private fun Collection.debugHistoryMovieSummary(limit: Int = 8): String = - take(limit).joinToString(separator = " | ") { item -> - buildString { - append(item.movie?.title ?: "unknown") - append(" ids=") - append(item.movie?.ids.debugIds()) - append(" watchedAt=") - append(item.watchedAt) - } - }.ifBlank { "none" } - -private fun Collection.debugWatchedShowSummary(limit: Int = 8): String = - take(limit).joinToString(separator = " | ") { item -> - val episodeCount = item.seasons.orEmpty().sumOf { season -> - season.episodes.orEmpty().count { episode -> - (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 - } - } - val latest = item.seasons.orEmpty() - .flatMap { season -> - val seasonNumber = season.number - season.episodes.orEmpty().mapNotNull { episode -> - val episodeNumber = episode.number ?: return@mapNotNull null - val watchedAt = episode.lastWatchedAt ?: item.lastWatchedAt - TraktWatchedShowEpisodeSeed( - season = seasonNumber ?: 0, - episode = episodeNumber, - watchedAt = watchedAt - ?.let { value -> - runCatching { TraktPlatformClock.parseIsoDateTimeToEpochMs(value) }.getOrNull() - } - ?: 0L, - ) - } - } - .maxWithOrNull( - compareBy( - { it.watchedAt }, - { it.season }, - { it.episode }, - ), - ) - buildString { - append(item.show?.title ?: "unknown") - append(" ids=") - append(item.show?.ids.debugIds()) - append(" episodes=") - append(episodeCount) - append(" latest=") - append(latest?.season) - append("x") - append(latest?.episode) - append(" lastWatchedAt=") - append(item.lastWatchedAt) - } - }.ifBlank { "none" } - -private fun TraktExternalIds?.debugIds(): String = - if (this == null) { - "none" - } else { - buildString { - imdb?.takeIf { it.isNotBlank() }?.let { - append("imdb:") - append(it) - } - tmdb?.let { - if (isNotEmpty()) append(",") - append("tmdb:") - append(it) - } - trakt?.let { - if (isNotEmpty()) append(",") - append("trakt:") - append(it) - } - slug?.takeIf { it.isNotBlank() }?.let { - if (isNotEmpty()) append(",") - append("slug:") - append(it) - } - }.ifBlank { "none" } - } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 80146409..083a3b93 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -1,6 +1,5 @@ package com.nuvio.app.features.watching.sync -import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.watchprogress.WatchProgressEntry import io.github.jan.supabase.postgrest.postgrest @@ -13,18 +12,16 @@ import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put object SupabaseProgressSyncAdapter : ProgressSyncAdapter { - private val log = Logger.withTag("NuvioSyncProgress") private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } override suspend fun pull(profileId: Int): List { - log.d { "pull start profileId=$profileId" } val params = buildJsonObject { put("p_profile_id", profileId) } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val serverEntries = result.decodeList() - val records = serverEntries.map { entry -> + return serverEntries.map { entry -> ProgressSyncRecord( contentId = entry.contentId, contentType = entry.contentType, @@ -36,21 +33,12 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { lastWatched = entry.lastWatched, ) } - log.d { - "pull returned raw=${serverEntries.size} records=${records.size} " + - "items=${records.debugProgressRecordSummary()}" - } - return records } override suspend fun push( profileId: Int, entries: Collection, ) { - log.d { - "push start profileId=$profileId entries=${entries.size} " + - "items=${entries.debugWatchProgressEntrySummary()}" - } val syncEntries = entries.map { entry -> WatchProgressSyncEntry( contentId = entry.parentMetaId, @@ -69,17 +57,12 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { put("p_entries", json.encodeToJsonElement(syncEntries)) } SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params) - log.d { "push complete profileId=$profileId entries=${syncEntries.size}" } } override suspend fun delete( profileId: Int, entries: Collection, ) { - log.d { - "delete start profileId=$profileId entries=${entries.size} " + - "items=${entries.debugWatchProgressEntrySummary()}" - } val progressKeys = entries.map { entry -> if (entry.seasonNumber != null && entry.episodeNumber != null) { "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" @@ -92,7 +75,6 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { put("p_keys", json.encodeToJsonElement(progressKeys)) } SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) - log.d { "delete complete profileId=$profileId keys=${progressKeys.joinToString(limit = 12)}" } } private fun progressKeyForEntry(entry: WatchProgressEntry): String = @@ -115,53 +97,3 @@ private data class WatchProgressSyncEntry( @SerialName("last_watched") val lastWatched: Long = 0, @SerialName("progress_key") val progressKey: String = "", ) - -private fun Collection.debugProgressRecordSummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { record -> - buildString { - append(record.contentType) - append(":") - append(record.contentId) - if (record.season != null || record.episode != null) { - append(" s=") - append(record.season) - append(" e=") - append(record.episode) - } - append(" video=") - append(record.videoId) - append(" pos=") - append(record.position) - append(" dur=") - append(record.duration) - append(" last=") - append(record.lastWatched) - } - }.ifBlank { "none" } - -private fun Collection.debugWatchProgressEntrySummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { entry -> - buildString { - append(entry.parentMetaType) - append(":") - append(entry.parentMetaId) - if (entry.seasonNumber != null || entry.episodeNumber != null) { - append(" s=") - append(entry.seasonNumber) - append(" e=") - append(entry.episodeNumber) - } - append(" video=") - append(entry.videoId) - append(" pos=") - append(entry.lastPositionMs) - append(" dur=") - append(entry.durationMs) - append(" pct=") - append(entry.progressPercent) - append(" completed=") - append(entry.isCompleted) - append(" last=") - append(entry.lastUpdatedEpochMs) - } - }.ifBlank { "none" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 6152fae8..19d6c046 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -60,6 +60,7 @@ internal object ContinueWatchingEnrichmentCache { } private const val storageKey = "cw_enrichment_cache" + private var lastPayloadHash: Int? = null fun getNextUpSnapshot(): List = loadPayload()?.nextUp ?: emptyList() @@ -75,11 +76,17 @@ internal object ContinueWatchingEnrichmentCache { fun saveSnapshots( nextUp: List, inProgress: List, + force: Boolean = false, ) { + val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress) + val payloadHash = payload.hashCode() + if (!force && lastPayloadHash == payloadHash) return + val encoded = runCatching { - json.encodeToString(CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress)) + json.encodeToString(payload) }.getOrNull() ?: return ContinueWatchingEnrichmentStorage.savePayload(ProfileScopedKey.of(storageKey), encoded) + lastPayloadHash = payloadHash } private fun loadPayload(): CachedEnrichmentPayload? { @@ -87,6 +94,8 @@ internal object ContinueWatchingEnrichmentCache { ?: return null return runCatching { json.decodeFromString(raw) - }.getOrNull() + }.getOrNull()?.also { payload -> + lastPayloadHash = payload.hashCode() + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 741bfd00..d452d3a8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -13,7 +13,6 @@ import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressSyncAdapter -import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -99,17 +98,7 @@ object WatchProgressRepository { if (authState !is AuthState.Authenticated || authState.isAnonymous) continue if (!hasCompletedInitialNuvioSyncPull || isPullingNuvioSyncFromServer) continue - log.d { - "periodic NuvioSync pull start profileId=${ProfileRepository.activeProfileId} " + - "entries=${entriesByVideoId.size}" - } runCatching { pullFromServer(ProfileRepository.activeProfileId) } - .onSuccess { - log.d { - "periodic NuvioSync pull complete profileId=${ProfileRepository.activeProfileId} " + - "entries=${entriesByVideoId.size}" - } - } .onFailure { error -> if (error is CancellationException) throw error log.w { "Periodic NuvioSync pull failed: ${error.message}" } @@ -171,13 +160,8 @@ object WatchProgressRepository { currentProfileId = profileId val useTraktProgress = shouldUseTraktProgress() - log.d { - "pullFromServer start profileId=$profileId source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " + - "localEntries=${entriesByVideoId.size}" - } if (!useTraktProgress && isPullingNuvioSyncFromServer) { - log.d { "pullFromServer NuvioSync skipped: pull already in flight profileId=$profileId" } return } if (!useTraktProgress) { @@ -192,20 +176,11 @@ object WatchProgressRepository { log.e(e) { "Failed to pull Trakt progress" } } publish() - log.d { - "pullFromServer trakt complete entries=${TraktProgressRepository.uiState.value.entries.size} " + - "sources=${TraktProgressRepository.uiState.value.entries.debugSourceCounts()} " + - "items=${TraktProgressRepository.uiState.value.entries.debugWatchProgressEntrySummary()}" - } return } runCatching { val serverEntries = syncAdapter.pull(profileId = profileId) - log.d { - "pullFromServer NuvioSync returned ${serverEntries.size} records " + - "items=${serverEntries.debugProgressRecordSummary()}" - } val oldLocal = entriesByVideoId.toMap() val newMap = mutableMapOf() @@ -244,10 +219,6 @@ object WatchProgressRepository { hasCompletedInitialNuvioSyncPull = true publish() persist() - log.d { - "pullFromServer NuvioSync applied entries=${entriesByVideoId.size} " + - "items=${entriesByVideoId.values.debugWatchProgressEntrySummary()}" - } resolveRemoteMetadata() }.onFailure { e -> @@ -267,14 +238,8 @@ object WatchProgressRepository { .groupBy { it.parentMetaId to it.contentType } if (needsResolution.isEmpty()) { - log.d { "resolveRemoteMetadata skipped: all entries have artwork" } return } - log.d { - "resolveRemoteMetadata start groups=${needsResolution.size} " + - "entries=${needsResolution.values.sumOf { it.size }} " + - "keys=${needsResolution.keys.joinToString(limit = 12) { (metaId, type) -> "$type:$metaId" }}" - } metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch { @@ -291,7 +256,6 @@ object WatchProgressRepository { MetaDetailsRepository.fetch(metaType, metaId) }.getOrNull() if (meta == null) { - log.d { "resolveRemoteMetadata miss type=$metaType id=$metaId entries=${entries.size}" } continue } @@ -316,13 +280,8 @@ object WatchProgressRepository { } publish() - log.d { - "resolveRemoteMetadata applied type=$metaType id=$metaId entries=${entries.size} " + - "metaVideos=${meta.videos.size}" - } } persist() - log.d { "resolveRemoteMetadata complete entries=${entriesByVideoId.size}" } } } @@ -447,10 +406,6 @@ object WatchProgressRepository { isEnded = snapshot.isEnded, ) if (!isCompleted && !shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) { - log.d { - "upsert skipped below threshold video=${session.videoId} content=${session.parentMetaId} " + - "s=${session.seasonNumber} e=${session.episodeNumber} pos=$positionMs dur=$durationMs ended=${snapshot.isEnded}" - } return } @@ -484,10 +439,6 @@ object WatchProgressRepository { } val useTraktProgress = shouldUseTraktProgress() - log.d { - "upsert progress source=${if (useTraktProgress) "trakt" else "nuvio_sync"} " + - "entry=${entry.debugSummary()} snapshotEnded=${snapshot.isEnded}" - } entriesByVideoId[session.videoId] = entry if (useTraktProgress) { @@ -508,9 +459,7 @@ object WatchProgressRepository { syncScope.launch { runCatching { val profileId = ProfileRepository.activeProfileId - log.d { "pushScrobbleToServer profileId=$profileId entry=${entry.debugSummary()}" } syncAdapter.push(profileId = profileId, entries = listOf(entry)) - log.d { "pushScrobbleToServer complete profileId=$profileId video=${entry.videoId}" } }.onFailure { e -> log.e(e) { "Failed to push watch progress scrobble" } } @@ -523,12 +472,7 @@ object WatchProgressRepository { runCatching { if (entries.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - log.d { - "pushDeleteToServer profileId=$profileId entries=${entries.size} " + - "items=${entries.debugWatchProgressEntrySummary()}" - } syncAdapter.delete(profileId = profileId, entries = entries) - log.d { "pushDeleteToServer complete profileId=$profileId entries=${entries.size}" } }.onFailure { e -> log.e(e) { "Failed to push watch progress delete" } } @@ -538,11 +482,6 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } - log.d { - "publish source=${if (shouldUseTraktProgress()) "trakt" else "nuvio_sync"} " + - "entries=${sortedEntries.size} cw=${sortedEntries.continueWatchingEntries().size} " + - "sources=${sortedEntries.debugSourceCounts()} items=${sortedEntries.debugWatchProgressEntrySummary()}" - } _uiState.value = WatchProgressUiState( entries = sortedEntries, hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { @@ -575,67 +514,3 @@ object WatchProgressRepository { } } - -private fun ProgressSyncRecord.debugSummary(): String = - buildString { - append(contentType) - append(":") - append(contentId) - if (season != null || episode != null) { - append(" s=") - append(season) - append(" e=") - append(episode) - } - append(" video=") - append(videoId) - append(" pos=") - append(position) - append(" dur=") - append(duration) - append(" last=") - append(lastWatched) - } - -private fun Collection.debugProgressRecordSummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } - -private fun WatchProgressEntry.debugSummary(): String = - buildString { - append(parentMetaType) - append(":") - append(parentMetaId) - if (seasonNumber != null || episodeNumber != null) { - append(" s=") - append(seasonNumber) - append(" e=") - append(episodeNumber) - } - append(" video=") - append(videoId) - append(" pos=") - append(lastPositionMs) - append(" dur=") - append(durationMs) - append(" pct=") - append(progressPercent) - append(" completed=") - append(isCompleted) - append(" effectiveCompleted=") - append(isEffectivelyCompleted) - append(" src=") - append(source) - append(" last=") - append(lastUpdatedEpochMs) - } - -private fun Collection.debugWatchProgressEntrySummary(limit: Int = 10): String = - take(limit).joinToString(separator = " | ") { it.debugSummary() }.ifBlank { "none" } - -private fun Collection.debugSourceCounts(): String = - groupingBy { it.source } - .eachCount() - .entries - .sortedBy { it.key } - .joinToString(separator = ",") { "${it.key}=${it.value}" } - .ifBlank { "none" } From 3e40e47b78e9d11c68c383f4668a22e07a2985c7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 02:21:25 +0530 Subject: [PATCH 07/19] feat(debrid): sorting and filtering --- .../debrid/DebridSettingsStorage.android.kt | 70 ++ .../composeResources/values/strings.xml | 7 + .../app/features/debrid/DebridSettings.kt | 243 ++++- .../debrid/DebridSettingsRepository.kt | 289 ++++- .../features/debrid/DebridSettingsStorage.kt | 14 + .../debrid/DirectDebridStreamFilter.kt | 390 ++++++- .../debrid/DirectDebridStreamSource.kt | 173 ++- .../features/settings/DebridSettingsPage.kt | 992 +++++++++++++++--- .../debrid/DirectDebridStreamFilterTest.kt | 148 ++- .../debrid/DebridSettingsStorage.ios.kt | 70 ++ 10 files changed, 2232 insertions(+), 164 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index 2ae1bccc..d1ff44e5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -19,6 +19,13 @@ actual object DebridSettingsStorage { private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" private val syncKeys = listOf( @@ -26,6 +33,13 @@ actual object DebridSettingsStorage { torboxApiKeyKey, realDebridApiKeyKey, instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -60,6 +74,48 @@ actual object DebridSettingsStorage { saveInt(instantPlaybackPreparationLimitKey, limit) } + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) actual fun saveStreamNameTemplate(template: String) { @@ -121,6 +177,13 @@ actual object DebridSettingsStorage { loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } } @@ -134,6 +197,13 @@ actual object DebridSettingsStorage { payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 955e47cd..d5112ecd 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -18,6 +18,7 @@ Resume Retry Save + Saving… Validate Installing Addons @@ -596,6 +597,10 @@ Add an API key first. Account Connect your Torbox account. + Torbox API Key + Enter your Torbox API key. + Enter Torbox API key + Not set Instant Playback Prepare links Resolve the first sources before playback starts. @@ -607,6 +612,8 @@ Controls how source names appear. Description template Controls the metadata shown under each source. + Reset formatting + Restore default source formatting. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index b2d40b0f..6e48cc07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -1,10 +1,19 @@ package com.nuvio.app.features.debrid +import kotlinx.serialization.Serializable + data class DebridSettings( val enabled: Boolean = false, val torboxApiKey: String = "", val realDebridApiKey: String = "", val instantPlaybackPreparationLimit: Int = 0, + val streamMaxResults: Int = 0, + val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT, + val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY, + val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY, + val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, ) { @@ -12,8 +21,236 @@ data class DebridSettings( get() = DebridProviders.configuredServices(this).isNotEmpty() } -internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 -internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 -internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = +enum class DebridStreamSortMode { + DEFAULT, + QUALITY_DESC, + SIZE_DESC, + SIZE_ASC, +} + +enum class DebridStreamMinimumQuality(val minResolution: Int) { + ANY(0), + P720(720), + P1080(1080), + P2160(2160), +} + +enum class DebridStreamFeatureFilter { + ANY, + EXCLUDE, + ONLY, +} + +enum class DebridStreamCodecFilter { + ANY, + H264, + HEVC, + AV1, +} + +@Serializable +data class DebridStreamPreferences( + val maxResults: Int = 0, + val maxPerResolution: Int = 0, + val maxPerQuality: Int = 0, + val sizeMinGb: Int = 0, + val sizeMaxGb: Int = 0, + val preferredResolutions: List = DebridStreamResolution.defaultOrder, + val requiredResolutions: List = emptyList(), + val excludedResolutions: List = emptyList(), + val preferredQualities: List = DebridStreamQuality.defaultOrder, + val requiredQualities: List = emptyList(), + val excludedQualities: List = emptyList(), + val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder, + val requiredVisualTags: List = emptyList(), + val excludedVisualTags: List = emptyList(), + val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder, + val requiredAudioTags: List = emptyList(), + val excludedAudioTags: List = emptyList(), + val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder, + val requiredAudioChannels: List = emptyList(), + val excludedAudioChannels: List = emptyList(), + val preferredEncodes: List = DebridStreamEncode.defaultOrder, + val requiredEncodes: List = emptyList(), + val excludedEncodes: List = emptyList(), + val preferredLanguages: List = emptyList(), + val requiredLanguages: List = emptyList(), + val excludedLanguages: List = emptyList(), + val requiredReleaseGroups: List = emptyList(), + val excludedReleaseGroups: List = emptyList(), + val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, +) + +@Serializable +enum class DebridStreamResolution(val label: String, val value: Int) { + P2160("2160p", 2160), + P1440("1440p", 1440), + P1080("1080p", 1080), + P720("720p", 720), + P576("576p", 576), + P480("480p", 480), + P360("360p", 360), + UNKNOWN("Unknown", 0); + + companion object { + val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamQuality(val label: String) { + BLURAY_REMUX("BluRay REMUX"), + BLURAY("BluRay"), + WEB_DL("WEB-DL"), + WEBRIP("WEBRip"), + HDRIP("HDRip"), + HD_RIP("HC HD-Rip"), + DVDRIP("DVDRip"), + HDTV("HDTV"), + CAM("CAM"), + TS("TS"), + TC("TC"), + SCR("SCR"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamVisualTag(val label: String) { + HDR_DV("HDR+DV"), + DV_ONLY("DV Only"), + HDR_ONLY("HDR Only"), + HDR10_PLUS("HDR10+"), + HDR10("HDR10"), + DV("DV"), + HDR("HDR"), + HLG("HLG"), + TEN_BIT("10bit"), + THREE_D("3D"), + IMAX("IMAX"), + AI("AI"), + SDR("SDR"), + H_OU("H-OU"), + H_SBS("H-SBS"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioTag(val label: String) { + ATMOS("Atmos"), + DD_PLUS("DD+"), + DD("DD"), + DTS_X("DTS:X"), + DTS_HD_MA("DTS-HD MA"), + DTS_HD("DTS-HD"), + DTS_ES("DTS-ES"), + DTS("DTS"), + TRUEHD("TrueHD"), + OPUS("OPUS"), + FLAC("FLAC"), + AAC("AAC"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioChannel(val label: String) { + CH_2_0("2.0"), + CH_5_1("5.1"), + CH_6_1("6.1"), + CH_7_1("7.1"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamEncode(val label: String) { + AV1("AV1"), + HEVC("HEVC"), + AVC("AVC"), + XVID("XviD"), + DIVX("DivX"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamLanguage(val code: String, val label: String) { + EN("en", "English"), + HI("hi", "Hindi"), + IT("it", "Italian"), + ES("es", "Spanish"), + FR("fr", "French"), + DE("de", "German"), + PT("pt", "Portuguese"), + PL("pl", "Polish"), + CS("cs", "Czech"), + LA("la", "Latino"), + JA("ja", "Japanese"), + KO("ko", "Korean"), + ZH("zh", "Chinese"), + MULTI("multi", "Multi"), + UNKNOWN("unknown", "Unknown"), +} + +@Serializable +data class DebridStreamSortCriterion( + val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION, + val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, +) { + companion object { + val defaultOrder = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } +} + +@Serializable +enum class DebridStreamSortKey(val label: String) { + RESOLUTION("Resolution"), + QUALITY("Quality"), + VISUAL_TAG("Visual tag"), + AUDIO_TAG("Audio"), + AUDIO_CHANNEL("Audio channel"), + ENCODE("Encode"), + SIZE("Size"), + LANGUAGE("Language"), + RELEASE_GROUP("Release group"), +} + +@Serializable +enum class DebridStreamSortDirection { + ASC, + DESC, +} + +fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) + +fun normalizeDebridStreamMaxResults(value: Int): Int = + if (value <= 0) 0 else value.coerceIn(1, 100) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index 17938a41..d8c7625b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -3,16 +3,34 @@ package com.nuvio.app.features.debrid import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object DebridSettingsRepository { private val _uiState = MutableStateFlow(DebridSettings()) val uiState: StateFlow = _uiState.asStateFlow() + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + private var hasLoaded = false private var enabled = false private var torboxApiKey = "" private var realDebridApiKey = "" private var instantPlaybackPreparationLimit = 0 + private var streamMaxResults = 0 + private var streamSortMode = DebridStreamSortMode.DEFAULT + private var streamMinimumQuality = DebridStreamMinimumQuality.ANY + private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY + private var streamHdrFilter = DebridStreamFeatureFilter.ANY + private var streamCodecFilter = DebridStreamCodecFilter.ANY + private var streamPreferences = DebridStreamPreferences() private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE @@ -68,6 +86,78 @@ object DebridSettingsRepository { DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized) } + fun setStreamMaxResults(value: Int) { + ensureLoaded() + val normalized = normalizeDebridStreamMaxResults(value) + if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return + streamMaxResults = normalized + streamPreferences = streamPreferences.copy(maxResults = normalized).normalized() + publish() + DebridSettingsStorage.saveStreamMaxResults(normalized) + saveStreamPreferences() + } + + fun setStreamSortMode(value: DebridStreamSortMode) { + ensureLoaded() + if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return + streamSortMode = value + streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized() + publish() + DebridSettingsStorage.saveStreamSortMode(value.name) + saveStreamPreferences() + } + + fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) { + ensureLoaded() + if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return + streamMinimumQuality = value + streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized() + publish() + DebridSettingsStorage.saveStreamMinimumQuality(value.name) + saveStreamPreferences() + } + + fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamDolbyVisionFilter == value) return + streamDolbyVisionFilter = value + streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name) + saveStreamPreferences() + } + + fun setStreamHdrFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamHdrFilter == value) return + streamHdrFilter = value + streamPreferences = streamPreferences.applyHdrFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamHdrFilter(value.name) + saveStreamPreferences() + } + + fun setStreamCodecFilter(value: DebridStreamCodecFilter) { + ensureLoaded() + if (streamCodecFilter == value) return + streamCodecFilter = value + streamPreferences = streamPreferences.applyCodecFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamCodecFilter(value.name) + saveStreamPreferences() + } + + fun setStreamPreferences(value: DebridStreamPreferences) { + ensureLoaded() + val normalized = value.normalized() + if (streamPreferences == normalized) return + streamPreferences = normalized + streamMaxResults = normalized.maxResults + publish() + DebridSettingsStorage.saveStreamMaxResults(streamMaxResults) + saveStreamPreferences() + } + fun setStreamNameTemplate(value: String) { ensureLoaded() val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } @@ -86,15 +176,22 @@ object DebridSettingsRepository { DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) } - fun resetStreamTemplates() { + fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { ensureLoaded() - streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE - streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } publish() DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) } + fun resetStreamTemplates() { + setStreamTemplates( + nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE, + descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ) + } + private fun disableIfNoKeys() { if (!hasVisibleApiKey()) { enabled = false @@ -114,6 +211,36 @@ object DebridSettingsRepository { instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, ) + streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0) + streamSortMode = enumValueOrDefault( + DebridSettingsStorage.loadStreamSortMode(), + DebridStreamSortMode.DEFAULT, + ) + streamMinimumQuality = enumValueOrDefault( + DebridSettingsStorage.loadStreamMinimumQuality(), + DebridStreamMinimumQuality.ANY, + ) + streamDolbyVisionFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamDolbyVisionFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamHdrFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamHdrFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamCodecFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamCodecFilter(), + DebridStreamCodecFilter.ANY, + ) + streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences()) + ?: legacyStreamPreferences( + maxResults = streamMaxResults, + sortMode = streamSortMode, + minimumQuality = streamMinimumQuality, + dolbyVisionFilter = streamDolbyVisionFilter, + hdrFilter = streamHdrFilter, + codecFilter = streamCodecFilter, + ) streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() ?.takeIf { it.isNotBlank() } ?: DebridStreamFormatterDefaults.NAME_TEMPLATE @@ -129,8 +256,164 @@ object DebridSettingsRepository { torboxApiKey = torboxApiKey, realDebridApiKey = realDebridApiKey, instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, + streamMaxResults = streamMaxResults, + streamSortMode = streamSortMode, + streamMinimumQuality = streamMinimumQuality, + streamDolbyVisionFilter = streamDolbyVisionFilter, + streamHdrFilter = streamHdrFilter, + streamCodecFilter = streamCodecFilter, + streamPreferences = streamPreferences, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, ) } + + private fun saveStreamPreferences() { + DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) + } + + private inline fun > enumValueOrDefault(value: String?, default: T): T = + runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) + + private fun parseStreamPreferences(value: String?): DebridStreamPreferences? { + if (value.isNullOrBlank()) return null + return try { + json.decodeFromString(value).normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } } + +internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = + copy( + maxResults = normalizeDebridStreamMaxResults(maxResults), + maxPerResolution = maxPerResolution.coerceIn(0, 100), + maxPerQuality = maxPerQuality.coerceIn(0, 100), + sizeMinGb = sizeMinGb.coerceIn(0, 100), + sizeMaxGb = sizeMaxGb.coerceIn(0, 100), + preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder }, + requiredResolutions = requiredResolutions, + excludedResolutions = excludedResolutions, + preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder }, + requiredQualities = requiredQualities, + excludedQualities = excludedQualities, + preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder }, + requiredVisualTags = requiredVisualTags, + excludedVisualTags = excludedVisualTags, + preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder }, + requiredAudioTags = requiredAudioTags, + excludedAudioTags = excludedAudioTags, + preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder }, + requiredAudioChannels = requiredAudioChannels, + excludedAudioChannels = excludedAudioChannels, + preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder }, + requiredEncodes = requiredEncodes, + excludedEncodes = excludedEncodes, + preferredLanguages = preferredLanguages, + requiredLanguages = requiredLanguages, + excludedLanguages = excludedLanguages, + requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + ) + +private fun legacyStreamPreferences( + maxResults: Int, + sortMode: DebridStreamSortMode, + minimumQuality: DebridStreamMinimumQuality, + dolbyVisionFilter: DebridStreamFeatureFilter, + hdrFilter: DebridStreamFeatureFilter, + codecFilter: DebridStreamCodecFilter, +): DebridStreamPreferences = + DebridStreamPreferences( + maxResults = normalizeDebridStreamMaxResults(maxResults), + sortCriteria = sortCriteriaForLegacyMode(sortMode), + requiredResolutions = resolutionsForMinimumQuality(minimumQuality), + ) + .applyDolbyVisionFilter(dolbyVisionFilter) + .applyHdrFilter(hdrFilter) + .applyCodecFilter(codecFilter) + .normalized() + +private fun DebridStreamPreferences.applyDolbyVisionFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyHdrFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = (excludedVisualTags + hdrTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + hdrTags).distinct(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyCodecFilter( + filter: DebridStreamCodecFilter, +): DebridStreamPreferences = + copy( + requiredEncodes = when (filter) { + DebridStreamCodecFilter.ANY -> emptyList() + DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC) + DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC) + DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1) + }, + ) + +private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List = + DebridStreamResolution.defaultOrder.filter { + it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN + } + +private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = + when (mode) { + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + } + +private val dolbyVisionTags = listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, +) + +private val hdrTags = listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 6c4f238f..62fddac4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -11,6 +11,20 @@ internal expect object DebridSettingsStorage { fun saveRealDebridApiKey(apiKey: String) fun loadInstantPlaybackPreparationLimit(): Int? fun saveInstantPlaybackPreparationLimit(limit: Int) + fun loadStreamMaxResults(): Int? + fun saveStreamMaxResults(maxResults: Int) + fun loadStreamSortMode(): String? + fun saveStreamSortMode(mode: String) + fun loadStreamMinimumQuality(): String? + fun saveStreamMinimumQuality(quality: String) + fun loadStreamDolbyVisionFilter(): String? + fun saveStreamDolbyVisionFilter(filter: String) + fun loadStreamHdrFilter(): String? + fun saveStreamHdrFilter(filter: String) + fun loadStreamCodecFilter(): String? + fun saveStreamCodecFilter(filter: String) + fun loadStreamPreferences(): String? + fun saveStreamPreferences(preferences: String) fun loadStreamNameTemplate(): String? fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt index 28c03dde..6647d607 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt @@ -5,8 +5,8 @@ import com.nuvio.app.features.streams.StreamItem object DirectDebridStreamFilter { const val FALLBACK_SOURCE_NAME = "Direct Debrid" - fun filterInstant(streams: List): List = - streams + fun filterInstant(streams: List, settings: DebridSettings? = null): List { + val instantStreams = streams .filter(::isInstantCandidate) .map { stream -> val providerId = stream.clientResolve?.service @@ -27,6 +27,8 @@ object DirectDebridStreamFilter { stream.title, ).joinToString("|") } + return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + } fun isInstantCandidate(stream: StreamItem): Boolean { val resolve = stream.clientResolve ?: return false @@ -37,5 +39,387 @@ object DirectDebridStreamFilter { fun isDirectDebridSourceName(addonName: String): Boolean = DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } -} + private fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = effectivePreferences(settings) + return streams.map { it to streamFacts(it, preferences) } + .filter { (_, facts) -> facts.matchesFilters(preferences) } + .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } + .let { sorted -> applyLimits(sorted, preferences) } + .map { it.first } + } + + private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + val default = DebridStreamPreferences() + if (settings.streamPreferences != default) return settings.streamPreferences.normalized() + if ( + settings.streamMaxResults == 0 && + settings.streamSortMode == DebridStreamSortMode.DEFAULT && + settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY && + settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY && + settings.streamHdrFilter == DebridStreamFeatureFilter.ANY && + settings.streamCodecFilter == DebridStreamCodecFilter.ANY + ) { + return default + } + var preferences = default.copy( + maxResults = settings.streamMaxResults, + sortCriteria = when (settings.streamSortMode) { + DebridStreamSortMode.DEFAULT -> default.sortCriteria + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + }, + requiredResolutions = DebridStreamResolution.defaultOrder.filter { + it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN + }, + ) + preferences = when (settings.streamDolbyVisionFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + preferences = when (settings.streamHdrFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + return when (settings.streamCodecFilter) { + DebridStreamCodecFilter.ANY -> preferences + DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC)) + DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC)) + DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1)) + }.normalized() + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: StreamFacts, + right: StreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: StreamFacts, + right: StreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } + + private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + val parsed = stream.clientResolve?.stream?.raw?.parsed + val searchText = streamSearchText(stream) + val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) + val quality = streamQuality(parsed?.quality, searchText) + val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText) + val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText) + val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText) + val encode = streamEncode(parsed?.codec, searchText) + val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty { + DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } + } + val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) + return StreamFacts( + resolution = resolution, + quality = quality, + visualTags = visualTags, + audioTags = audioTags, + audioChannels = audioChannels, + encode = encode, + languages = languages, + releaseGroup = releaseGroup, + size = streamSize(stream), + resolutionRank = rank(resolution, preferences.preferredResolutions), + qualityRank = rank(quality, preferences.preferredQualities), + visualRank = rankAny(visualTags, preferences.preferredVisualTags), + audioRank = rankAny(audioTags, preferences.preferredAudioTags), + channelRank = rankAny(audioChannels, preferences.preferredAudioChannels), + encodeRank = rank(encode, preferences.preferredEncodes), + languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) }, + ) + } + + private fun streamResolution(vararg values: String?): DebridStreamResolution = + values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN + + private fun resolutionValue(value: String?): DebridStreamResolution? { + val normalized = value?.lowercase().orEmpty() + return when { + normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160 + normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440 + normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080 + normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720 + normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576 + normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480 + normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360 + else -> null + } + } + + private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality { + val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase() + return when { + text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX + text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY + text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL + text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP + text.contains("hdrip") -> DebridStreamQuality.HDRIP + text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP + text.contains("dvdrip") -> DebridStreamQuality.DVDRIP + text.contains("hdtv") -> DebridStreamQuality.HDTV + text.hasToken("cam") -> DebridStreamQuality.CAM + text.hasToken("ts") -> DebridStreamQuality.TS + text.hasToken("tc") -> DebridStreamQuality.TC + text.hasToken("scr") -> DebridStreamQuality.SCR + else -> DebridStreamQuality.UNKNOWN + } + } + + private fun streamVisualTags(parsedHdr: List, searchText: String): List { + val text = (parsedHdr + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + val hasHdr = parsedHdr.any { it.isHdrToken() } || + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV + if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY + if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY + if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS + if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10 + if (hasDv) tags += DebridStreamVisualTag.DV + if (hasHdr) tags += DebridStreamVisualTag.HDR + if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG + if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT + if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D + if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX + if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI + if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR + if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU + if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS + return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) } + } + + private fun streamAudioTags(parsedAudio: List, searchText: String): List { + val text = (parsedAudio + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS + if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS + if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD + if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X + if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA + if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD + if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES + if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS + if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD + if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS + if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC + if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC + return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) } + } + + private fun streamAudioChannels(parsedChannels: List, searchText: String): List { + val text = (parsedChannels + searchText).joinToString(" ").lowercase() + val channels = mutableListOf() + if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1 + if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1 + if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1 + if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0 + return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) } + } + + private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode { + val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase() + return when { + text.hasToken("av1") -> DebridStreamEncode.AV1 + text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC + text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC + text.hasToken("xvid") -> DebridStreamEncode.XVID + text.hasToken("divx") -> DebridStreamEncode.DIVX + else -> DebridStreamEncode.UNKNOWN + } + } + + private fun languageFor(value: String): DebridStreamLanguage? { + val normalized = value.lowercase() + return DebridStreamLanguage.entries.firstOrNull { + normalized == it.code || normalized == it.label.lowercase() + } + } + + private fun releaseGroupFromText(text: String): String = + Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE) + .find(text) + ?.groupValues + ?.getOrNull(1) + .orEmpty() + + private fun rank(value: T, preferred: List): Int { + val index = preferred.indexOf(value) + return if (index >= 0) index else Int.MAX_VALUE + } + + private fun rankAny(values: List, preferred: List): Int = + values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE + + private fun String.hasResolutionToken(vararg tokens: String): Boolean = + Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this) + + private fun String.hasToken(token: String): Boolean = + Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase()) + + private fun String.isDolbyVisionToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9]"), "") + return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision" + } + + private fun String.isHdrToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "") + return normalized == "hdr" || + normalized == "hdr10" || + normalized == "hdr10+" || + normalized == "hdr10plus" || + normalized == "hlg" + } + + private fun streamSize(stream: StreamItem): Long? = + stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + + private fun streamSearchText(stream: StreamItem): String { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + return listOfNotNull( + stream.name, + stream.title, + stream.description, + resolve?.torrentName, + resolve?.filename, + raw?.torrentName, + raw?.filename, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.hdr?.joinToString(" "), + parsed?.audio?.joinToString(" "), + ).joinToString(" ").lowercase() + } + + private fun Int.gigabytes(): Long = this * 1_000_000_000L + + private data class StreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt index 7cd0e03a..6cd5573d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -4,9 +4,20 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamParser +import com.nuvio.app.features.streams.epochMs import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" +private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L data class DirectDebridStreamTarget( val provider: DebridProvider, @@ -20,6 +31,10 @@ object DirectDebridStreamSource { private val log = Logger.withTag(DIRECT_DEBRID_TAG) private val encoder = DirectDebridConfigEncoder() private val formatter = DebridStreamFormatter() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val streamCache = mutableMapOf() + private val inFlightFetches = mutableMapOf>() fun configuredTargets(): List { DebridSettingsRepository.ensureLoaded() @@ -33,6 +48,12 @@ object DirectDebridStreamSource { } } + fun sourceNames(): List = + configuredTargets().map { it.addonName } + + fun isEnabled(): Boolean = + sourceNames().isNotEmpty() + fun placeholders(): List = configuredTargets().map { target -> AddonStreamGroup( @@ -43,6 +64,36 @@ object DirectDebridStreamSource { ) } + fun preloadStreams(type: String, videoId: String) { + if (type.isBlank() || videoId.isBlank()) return + configuredTargets().forEach { target -> + scope.launch { + runCatching { fetchProviderStreams(type, videoId, target) } + } + } + } + + suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { + val targets = configuredTargets() + if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled + + val results = mutableListOf() + val errors = mutableListOf() + targets.forEach { target -> + val group = fetchProviderStreams(type, videoId, target) + when { + group.streams.isNotEmpty() -> results += group + !group.error.isNullOrBlank() -> errors += group.error + } + } + + return when { + results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) + errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) + else -> DirectDebridStreamFetchResult.Empty + } + } + suspend fun fetchProviderStreams( type: String, videoId: String, @@ -54,6 +105,89 @@ object DirectDebridStreamSource { return target.emptyGroup() } + val cacheKey = DirectDebridStreamCacheKey( + providerId = target.provider.id, + type = type.trim().lowercase(), + videoId = videoId.trim(), + baseUrl = baseUrl, + settingsFingerprint = settings.toString(), + ) + cachedGroup(cacheKey)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchProviderStreamsUncached( + baseUrl = baseUrl, + type = type, + videoId = videoId, + target = target, + settings = settings, + ) + } + val activeFetch = mutex.withLock { + cachedGroupLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightFetches[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightFetches[cacheKey] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch") + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + if (ownsFetch && result.streams.isNotEmpty() && result.error == null) { + mutex.withLock { + streamCache[cacheKey] = CachedDirectDebridStreams( + group = result, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlightFetches[cacheKey] === deferred) { + inFlightFetches.remove(cacheKey) + } + } + } + } + } + + private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? = + mutex.withLock { cachedGroupLocked(cacheKey) } + + private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? { + val cached = streamCache[cacheKey] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..STREAM_CACHE_TTL_MS) { + cached.group + } else { + streamCache.remove(cacheKey) + null + } + } + + private suspend fun fetchProviderStreamsUncached( + baseUrl: String, + type: String, + videoId: String, + target: DirectDebridStreamTarget, + settings: DebridSettings, + ): AddonStreamGroup { val credential = DebridServiceCredential(target.provider, target.apiKey) val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" return try { @@ -63,7 +197,7 @@ object DirectDebridStreamSource { addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, addonId = target.addonId, ) - .let(DirectDebridStreamFilter::filterInstant) + .let { DirectDebridStreamFilter.filterInstant(it, settings) } .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } @@ -76,13 +210,7 @@ object DirectDebridStreamSource { } catch (error: Exception) { if (error is CancellationException) throw error log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = false, - error = error.message, - ) + target.errorGroup(error.message) } } @@ -93,4 +221,33 @@ object DirectDebridStreamSource { streams = emptyList(), isLoading = false, ) + + private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + error = message, + ) +} + +private data class DirectDebridStreamCacheKey( + val providerId: String, + val type: String, + val videoId: String, + val baseUrl: String, + val settingsFingerprint: String, +) + +private data class CachedDirectDebridStreams( + val group: AddonStreamGroup, + val createdAtMs: Long, +) + +sealed class DirectDebridStreamFetchResult { + data object Disabled : DirectDebridStreamFetchResult() + data object Empty : DirectDebridStreamFetchResult() + data class Success(val streams: List) : DirectDebridStreamFetchResult() + data class Error(val message: String) : DirectDebridStreamFetchResult() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index a618b8ed..9c2fdfda 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -4,16 +4,23 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,6 +32,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -37,26 +45,40 @@ import com.nuvio.app.features.debrid.DebridCredentialValidator import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamAudioChannel +import com.nuvio.app.features.debrid.DebridStreamAudioTag +import com.nuvio.app.features.debrid.DebridStreamEncode +import com.nuvio.app.features.debrid.DebridStreamLanguage +import com.nuvio.app.features.debrid.DebridStreamPreferences +import com.nuvio.app.features.debrid.DebridStreamQuality +import com.nuvio.app.features.debrid.DebridStreamResolution +import com.nuvio.app.features.debrid.DebridStreamSortCriterion +import com.nuvio.app.features.debrid.DebridStreamSortDirection +import com.nuvio.app.features.debrid.DebridStreamSortKey +import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_clear import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_save -import nuvio.composeapp.generated.resources.action_validate +import nuvio.composeapp.generated.resources.action_saving import nuvio.composeapp.generated.resources.settings_debrid_add_key_first -import nuvio.composeapp.generated.resources.settings_debrid_description_template -import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder +import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_dialog_title import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count -import nuvio.composeapp.generated.resources.settings_debrid_key_valid import nuvio.composeapp.generated.resources.settings_debrid_key_invalid -import nuvio.composeapp.generated.resources.settings_debrid_name_template -import nuvio.composeapp.generated.resources.settings_debrid_name_template_description +import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting @@ -82,7 +104,7 @@ internal fun LazyListScope.debridSettingsContent( SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_enable), description = stringResource(Res.string.settings_debrid_enable_description), - checked = settings.enabled, + checked = settings.enabled && settings.hasAnyApiKey, enabled = settings.hasAnyApiKey, isTablet = isTablet, onCheckedChange = DebridSettingsRepository::setEnabled, @@ -99,21 +121,35 @@ internal fun LazyListScope.debridSettingsContent( } item { + var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } + SettingsSection( title = stringResource(Res.string.settings_debrid_section_providers), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridApiKeyRow( + DebridPreferenceRow( isTablet = isTablet, - providerId = DebridProviders.TORBOX_ID, title = DebridProviders.Torbox.displayName, description = stringResource(Res.string.settings_debrid_provider_torbox_description), - value = settings.torboxApiKey, - onApiKeyCommitted = DebridSettingsRepository::setTorboxApiKey, + value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)), + enabled = true, + onClick = { showApiKeyDialog = true }, ) } } + + if (showApiKeyDialog) { + DebridApiKeyDialog( + providerId = DebridProviders.TORBOX_ID, + title = stringResource(Res.string.settings_debrid_dialog_title), + subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle), + placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder), + currentValue = settings.torboxApiKey, + onSave = DebridSettingsRepository::setTorboxApiKey, + onDismiss = { showApiKeyDialog = false }, + ) + } } item { @@ -162,39 +198,98 @@ internal fun LazyListScope.debridSettingsContent( } } + item { + var activeStreamPicker by rememberSaveable { mutableStateOf(null) } + val preferences = settings.streamPreferences + val rows = debridRuleRows(preferences) + + SettingsSection( + title = "Filters & Sorting", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = "Max results", + description = "Limit how many Direct Debrid sources appear.", + value = streamMaxResultsLabel(preferences.maxResults), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Sort streams", + description = "Choose how Direct Debrid sources are ordered.", + value = sortProfileLabel(preferences.sortCriteria), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per resolution limit", + description = "Cap repeated 2160p, 1080p, 720p results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerResolution), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per quality limit", + description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerQuality), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Size range", + description = "Filter streams by file size.", + value = sizeRangeLabel(preferences), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, + ) + rows.forEach { row -> + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = row.title, + description = row.description, + value = row.value, + enabled = settings.enabled, + onClick = { activeStreamPicker = row.picker }, + ) + } + } + } + + activeStreamPicker?.let { picker -> + DebridStreamPreferenceDialog( + picker = picker, + preferences = preferences, + onPreferencesChanged = DebridSettingsRepository::setStreamPreferences, + onDismiss = { activeStreamPicker = null }, + ) + } + } + item { SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridTemplateRow( + DebridPreferenceRow( isTablet = isTablet, - title = stringResource(Res.string.settings_debrid_name_template), - description = stringResource(Res.string.settings_debrid_name_template_description), - value = settings.streamNameTemplate, - singleLine = true, - onTemplateCommitted = DebridSettingsRepository::setStreamNameTemplate, + title = stringResource(Res.string.settings_debrid_formatter_reset_title), + description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle), + value = stringResource(Res.string.action_reset), + enabled = settings.enabled, + onClick = DebridSettingsRepository::resetStreamTemplates, ) - SettingsGroupDivider(isTablet = isTablet) - DebridTemplateRow( - isTablet = isTablet, - title = stringResource(Res.string.settings_debrid_description_template), - description = stringResource(Res.string.settings_debrid_description_template_description), - value = settings.streamDescriptionTemplate, - singleLine = false, - onTemplateCommitted = DebridSettingsRepository::setStreamDescriptionTemplate, - ) - SettingsGroupDivider(isTablet = isTablet) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 12.dp), - ) { - TextButton(onClick = DebridSettingsRepository::resetStreamTemplates) { - Text(stringResource(Res.string.action_reset)) - } - } } } } @@ -286,31 +381,28 @@ private fun DebridPrepareCountDialog( } @Composable -private fun DebridApiKeyRow( +private fun DebridPreferenceRow( isTablet: Boolean, - providerId: String, title: String, description: String, value: String, - onApiKeyCommitted: (String) -> Unit, + enabled: Boolean, + onClick: () -> Unit, ) { val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 16.dp else 14.dp - val scope = rememberCoroutineScope() - var draft by rememberSaveable(value) { mutableStateOf(value) } - var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } - var validationMessage by rememberSaveable(providerId, value) { mutableStateOf(null) } - val normalizedDraft = draft.trim() - val validMessage = stringResource(Res.string.settings_debrid_key_valid) - val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) - - Column( + Row( modifier = Modifier .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = title, style = MaterialTheme.typography.bodyLarge, @@ -323,116 +415,732 @@ private fun DebridApiKeyRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - SettingsSecretTextField( - value = draft, - onValueChange = { - draft = it - validationMessage = null - }, - modifier = Modifier.fillMaxWidth(), - label = "$title API key", + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, ) + } +} - validationMessage?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } +@Composable +private fun DebridStreamPreferenceDialog( + picker: DebridStreamPicker, + preferences: DebridStreamPreferences, + onPreferencesChanged: (DebridStreamPreferences) -> Unit, + onDismiss: () -> Unit, +) { + when (picker) { + DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxResults, + options = listOf(0, 5, 10, 20, 50), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxResults = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerResolution, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerResolution = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerQuality, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerQuality = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( + title = "Sort streams", + selectedValue = sortProfileFor(preferences.sortCriteria), + options = listOf( + DebridSortProfile.DEFAULT, + DebridSortProfile.LARGEST, + DebridSortProfile.SMALLEST, + DebridSortProfile.AUDIO, + DebridSortProfile.LANGUAGE, + ), + label = { sortProfileLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sortCriteria = sortCriteriaForProfile(value))) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog( + title = "Size range", + selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb, + options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100), + label = { sizeRangeLabel(it.first, it.second) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sizeMinGb = value.first, sizeMaxGb = value.second)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Preferred resolutions", + selectedValues = preferences.preferredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredResolutions = value.ifEmpty { DebridStreamResolution.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Required resolutions", + selectedValues = preferences.requiredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Excluded resolutions", + selectedValues = preferences.excludedResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Preferred qualities", + selectedValues = preferences.preferredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredQualities = value.ifEmpty { DebridStreamQuality.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Required qualities", + selectedValues = preferences.requiredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog( + title = "Excluded qualities", + selectedValues = preferences.excludedQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Preferred visual tags", + selectedValues = preferences.preferredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredVisualTags = value.ifEmpty { DebridStreamVisualTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Required visual tags", + selectedValues = preferences.requiredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Excluded visual tags", + selectedValues = preferences.excludedVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Preferred audio tags", + selectedValues = preferences.preferredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioTags = value.ifEmpty { DebridStreamAudioTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Required audio tags", + selectedValues = preferences.requiredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Excluded audio tags", + selectedValues = preferences.excludedAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Preferred channels", + selectedValues = preferences.preferredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioChannels = value.ifEmpty { DebridStreamAudioChannel.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Required channels", + selectedValues = preferences.requiredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Excluded channels", + selectedValues = preferences.excludedAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog( + title = "Preferred encodes", + selectedValues = preferences.preferredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredEncodes = value.ifEmpty { DebridStreamEncode.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog( + title = "Required encodes", + selectedValues = preferences.requiredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog( + title = "Excluded encodes", + selectedValues = preferences.excludedEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Preferred languages", + selectedValues = preferences.preferredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Required languages", + selectedValues = preferences.requiredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Excluded languages", + selectedValues = preferences.excludedLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Required release groups", + selectedValues = preferences.requiredReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Excluded release groups", + selectedValues = preferences.excludedReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - draft = normalizedDraft - onApiKeyCommitted(normalizedDraft) - }, - enabled = normalizedDraft != value && !isValidating, +@Composable +private fun DebridIntChoiceDialog( + title: String, + selectedValue: Int, + options: List, + label: @Composable (Int) -> String, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + DebridSingleChoiceDialog( + title = title, + selectedValue = selectedValue, + options = options, + label = label, + onSelected = onSelected, + onDismiss = onDismiss, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridSingleChoiceDialog( + title: String, + selectedValue: T, + options: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(Res.string.action_save)) - } - TextButton( - onClick = { - scope.launch { - isValidating = true - val valid = runCatching { - DebridCredentialValidator.validateProvider(providerId, normalizedDraft) - }.getOrDefault(false) - validationMessage = if (valid) validMessage else invalidMessage - isValidating = false - } - }, - enabled = normalizedDraft.isNotBlank() && !isValidating, - ) { - Text(stringResource(Res.string.action_validate)) + items(options) { option -> + DebridDialogOptionRow( + text = label(option), + selected = option == selectedValue, + onClick = { + onSelected(option) + onDismiss() + }, + ) + } } } } } @Composable -private fun DebridTemplateRow( - isTablet: Boolean, +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridMultiChoiceDialog( title: String, - description: String, - value: String, - singleLine: Boolean, - onTemplateCommitted: (String) -> Unit, + selectedValues: List, + values: List, + label: @Composable (T) -> String, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, ) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 16.dp else 14.dp - var draft by rememberSaveable(value) { mutableStateOf(value) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = singleLine, - minLines = if (singleLine) 1 else 4, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - - Row(modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { onTemplateCommitted(draft) }, - enabled = draft != value, + var draft by remember(selectedValues) { mutableStateOf(selectedValues) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(Res.string.action_save)) + items(values) { option -> + val selected = option in draft + DebridDialogOptionRow( + text = label(option), + selected = selected, + showCheckbox = true, + onClick = { + draft = if (selected) { + draft - option + } else { + draft + option + } + }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { draft = emptyList() }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(draft) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } } } } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTextListDialog( + title: String, + selectedValues: List, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember(selectedValues) { mutableStateOf(selectedValues.joinToString("\n")) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = "Enter one group per line.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + minLines = 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { value = "" }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(value.split('\n', ',').map { it.trim() }.filter { it.isNotBlank() }.distinct()) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +private fun DebridDialogSurface( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + 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 = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + content() + Spacer(modifier = Modifier.height(2.dp)) + } + } +} + +@Composable +private fun DebridDialogOptionRow( + text: String, + selected: Boolean, + showCheckbox: Boolean = false, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (showCheckbox) { + Checkbox( + checked = selected, + onCheckedChange = { onClick() }, + ) + } else { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +@Composable +private fun streamMaxResultsLabel(value: Int): String = + if (value <= 0) "All streams" else "$value streams" + +private fun sortProfileLabel(value: DebridSortProfile): String = + when (value) { + DebridSortProfile.DEFAULT -> "Default" + DebridSortProfile.LARGEST -> "Largest first" + DebridSortProfile.SMALLEST -> "Smallest first" + DebridSortProfile.AUDIO -> "Best audio first" + DebridSortProfile.LANGUAGE -> "Language first" + } + +private fun debridRuleRows(preferences: DebridStreamPreferences): List = + listOf( + DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)), + DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)), + DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected source qualities.", selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected source qualities.", selectionCountLabel(preferences.excludedQualities)), + DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)), + DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)), + DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)), + DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show streams with selected languages.", selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide streams where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)), + ) + +private fun selectionCountLabel(values: List<*>): String = + if (values.isEmpty()) "Any" else "${values.size} selected" + +private fun sizeRangeLabel(preferences: DebridStreamPreferences): String = + sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb) + +private fun sizeRangeLabel(minGb: Int, maxGb: Int): String = + when { + minGb <= 0 && maxGb <= 0 -> "Any" + minGb <= 0 -> "Up to ${maxGb}GB" + maxGb <= 0 -> "${minGb}GB+" + else -> "${minGb}-${maxGb}GB" + } + +private fun sortProfileFor(criteria: List): DebridSortProfile { + val normalized = criteria.map { it.key to it.direction } + return when { + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST + normalized.take(2) == listOf( + DebridStreamSortKey.AUDIO_TAG to DebridStreamSortDirection.DESC, + DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC, + ) -> DebridSortProfile.AUDIO + normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE + else -> DebridSortProfile.DEFAULT + } +} + +private fun sortProfileLabel(criteria: List): String = + sortProfileLabel(sortProfileFor(criteria)) + +private fun sortCriteriaForProfile(profile: DebridSortProfile): List = + when (profile) { + DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridSortProfile.AUDIO -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridSortProfile.LANGUAGE -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.LANGUAGE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } + +private data class DebridRuleRow( + val picker: DebridStreamPicker, + val title: String, + val description: String, + val value: String, +) + +private enum class DebridSortProfile { + DEFAULT, + LARGEST, + SMALLEST, + AUDIO, + LANGUAGE, +} + +private enum class DebridStreamPicker { + MAX_RESULTS, + MAX_PER_RESOLUTION, + MAX_PER_QUALITY, + SORT_MODE, + SIZE_RANGE, + PREFERRED_RESOLUTIONS, + REQUIRED_RESOLUTIONS, + EXCLUDED_RESOLUTIONS, + PREFERRED_QUALITIES, + REQUIRED_QUALITIES, + EXCLUDED_QUALITIES, + PREFERRED_VISUAL_TAGS, + REQUIRED_VISUAL_TAGS, + EXCLUDED_VISUAL_TAGS, + PREFERRED_AUDIO_TAGS, + REQUIRED_AUDIO_TAGS, + EXCLUDED_AUDIO_TAGS, + PREFERRED_AUDIO_CHANNELS, + REQUIRED_AUDIO_CHANNELS, + EXCLUDED_AUDIO_CHANNELS, + PREFERRED_ENCODES, + REQUIRED_ENCODES, + EXCLUDED_ENCODES, + PREFERRED_LANGUAGES, + REQUIRED_LANGUAGES, + EXCLUDED_LANGUAGES, + REQUIRED_RELEASE_GROUPS, + EXCLUDED_RELEASE_GROUPS, +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridApiKeyDialog( + providerId: String, + title: String, + subtitle: String, + placeholder: String, + currentValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } + var validationMessage by rememberSaveable(providerId, currentValue) { mutableStateOf(null) } + val normalizedDraft = draft.trim() + val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) + val saveAndDismiss: () -> Unit = { + scope.launch { + isValidating = true + validationMessage = null + val valid = normalizedDraft.isNotBlank() && runCatching { + DebridCredentialValidator.validateProvider(providerId, normalizedDraft) + }.getOrDefault(false) + if (valid) { + onSave(normalizedDraft) + isValidating = false + onDismiss() + } else { + validationMessage = invalidMessage + isValidating = false + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { + draft = it + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + TextButton( + onClick = { + onSave("") + onDismiss() + }, + enabled = !isValidating, + ) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = saveAndDismiss, + enabled = normalizedDraft.isNotBlank() && !isValidating, + ) { + Text( + if (isValidating) { + stringResource(Res.string.action_saving) + } else { + stringResource(Res.string.action_save) + }, + ) + } + } + } + } +} + +private fun maskDebridApiKey(key: String, notSetLabel: String): String { + val trimmed = key.trim() + if (trimmed.isBlank()) return notSetLabel + return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" +} + @Composable private fun DebridInfoRow( isTablet: Boolean, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt index 271f6f42..593fa6af 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt @@ -1,6 +1,9 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamClientResolveRaw +import com.nuvio.app.features.streams.StreamClientResolveStream import com.nuvio.app.features.streams.StreamItem import kotlin.test.Test import kotlin.test.assertEquals @@ -48,25 +51,160 @@ class DirectDebridStreamFilterTest { assertTrue(plainTorrent.isTorrentStream) } + @Test + fun `sorts and limits streams by quality and size`() { + val streams = listOf( + stream(resolution = "1080p", size = 20), + stream(resolution = "2160p", size = 10), + stream(resolution = "2160p", size = 30), + stream(resolution = "720p", size = 40), + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + streams, + DebridSettings( + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + ), + ) + + assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `filters minimum quality dolby vision hdr and codec`() { + val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10) + val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20) + val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30) + val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40) + + val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings( + streamMinimumQuality = DebridStreamMinimumQuality.P2160, + streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE, + streamHdrFilter = DebridStreamFeatureFilter.ONLY, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ) + + assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size }) + + val dvOnly = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY), + ) + + assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `applies stream preference filters and sort criteria`() { + val remuxAtmos = stream( + resolution = "2160p", + quality = "BluRay REMUX", + codec = "HEVC", + audio = listOf("Atmos", "TrueHD"), + channels = listOf("7.1"), + languages = listOf("en"), + group = "GOOD", + size = 40_000_000_000, + ) + val webAac = stream( + resolution = "2160p", + quality = "WEB-DL", + codec = "AVC", + audio = listOf("AAC"), + channels = listOf("2.0"), + languages = listOf("en"), + group = "NOPE", + size = 4_000_000_000, + ) + val blurayDts = stream( + resolution = "1080p", + quality = "BluRay", + codec = "AVC", + audio = listOf("DTS"), + channels = listOf("5.1"), + languages = listOf("hi"), + group = "GOOD", + size = 12_000_000_000, + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + listOf(webAac, blurayDts, remuxAtmos), + DebridSettings( + streamPreferences = DebridStreamPreferences( + maxResults = 2, + maxPerResolution = 1, + sizeMinGb = 5, + requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080), + excludedQualities = listOf(DebridStreamQuality.WEB_DL), + requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1), + excludedEncodes = listOf(DebridStreamEncode.UNKNOWN), + excludedLanguages = listOf(DebridStreamLanguage.IT), + requiredReleaseGroups = listOf("GOOD"), + sortCriteria = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), + ), + ), + ), + ) + + assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + private fun stream( - service: String?, - cached: Boolean?, + service: String? = DebridProviders.TORBOX_ID, + cached: Boolean? = true, type: String = "debrid", infoHash: String = "hash", fileIdx: Int = 1, + resolution: String? = null, + quality: String? = null, + hdr: List = emptyList(), + codec: String? = null, + audio: List = emptyList(), + channels: List = emptyList(), + languages: List = emptyList(), + group: String? = null, + size: Long? = null, ): StreamItem = StreamItem( - name = "Stream", + name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", + description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", addonName = "Direct Debrid", addonId = "debrid", clientResolve = StreamClientResolve( type = type, service = service, isCached = cached, - infoHash = infoHash, + infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), fileIdx = fileIdx, - filename = "video.mkv", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + size = size, + folderSize = size, + parsed = StreamClientResolveParsed( + resolution = resolution, + quality = quality, + hdr = hdr, + codec = codec, + audio = audio, + channels = channels, + languages = languages, + group = group, + ), + ), + ), ), ) } +private fun Long?.orEmptyHashPart(): String = + this?.toString().orEmpty() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index 0ac46039..dc85c449 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -17,6 +17,13 @@ actual object DebridSettingsStorage { private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" private val syncKeys = listOf( @@ -24,6 +31,13 @@ actual object DebridSettingsStorage { torboxApiKeyKey, realDebridApiKeyKey, instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -52,6 +66,48 @@ actual object DebridSettingsStorage { saveInt(instantPlaybackPreparationLimitKey, limit) } + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) actual fun saveStreamNameTemplate(template: String) { @@ -104,6 +160,13 @@ actual object DebridSettingsStorage { loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } } @@ -117,6 +180,13 @@ actual object DebridSettingsStorage { payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } From a58f1720102ba387a0312eb8733ec65afa801159 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 03:16:24 +0530 Subject: [PATCH 08/19] feat(debrid): stream cache --- .../app/features/details/MetaDetailsScreen.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf27..99493f36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -62,6 +62,7 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet import com.nuvio.app.features.details.components.DetailAdditionalInfoSection @@ -372,6 +373,16 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) { + if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) { + seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id + } else { + meta.id + } + } + LaunchedEffect(meta.type, debridPreloadVideoId) { + DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1259,3 +1270,8 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean = + hasEpisodes || type.equals("series", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tv", ignoreCase = true) From 873f81a954ddf74f7ad4dc2532539c9f4f8e16fd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 21:04:39 +0530 Subject: [PATCH 09/19] feat(ios): adding custom tuning for mpv --- .../player/PlayerSettingsStorage.android.kt | 170 ++++++++++ .../features/player/IosVideoSettingsModal.kt | 317 ++++++++++++++++++ .../app/features/player/PlayerControls.kt | 11 + .../nuvio/app/features/player/PlayerEngine.kt | 1 + .../nuvio/app/features/player/PlayerModels.kt | 67 ++++ .../nuvio/app/features/player/PlayerScreen.kt | 20 ++ .../player/PlayerSettingsRepository.kt | 238 +++++++++++++ .../features/player/PlayerSettingsStorage.kt | 28 ++ .../features/settings/PlaybackSettingsPage.kt | 182 ++++++++++ .../app/features/player/NuvioPlayerBridge.kt | 15 + .../app/features/player/PlayerEngine.ios.kt | 32 ++ .../player/PlayerSettingsStorage.ios.kt | 170 ++++++++++ iosApp/iosApp/Player/MPVPlayerBridge.swift | 72 +++- 13 files changed, 1322 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt 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 5cb861a8..d6e11982 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 @@ -56,6 +56,20 @@ actual object PlayerSettingsStorage { private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val useLibassKey = "use_libass" private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, resizeModeKey, @@ -92,6 +106,20 @@ actual object PlayerSettingsStorage { nextEpisodeThresholdMinutesBeforeEndKey, useLibassKey, libassRenderTypeKey, + iosVideoOutputPresetKey, + iosToneMappingModeKey, + iosTargetPrimariesKey, + iosTargetTransferKey, + iosHardwareDecoderModeKey, + iosExtendedDynamicRangeEnabledKey, + iosTargetColorspaceHintEnabledKey, + iosHdrComputePeakEnabledKey, + iosDebandEnabledKey, + iosInterpolationEnabledKey, + iosBrightnessKey, + iosContrastKey, + iosSaturationKey, + iosGammaKey, ) private var preferences: SharedPreferences? = null @@ -652,6 +680,120 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadIosVideoOutputPreset(): String? = + preferences?.getString(ProfileScopedKey.of(iosVideoOutputPresetKey), null) + + actual fun saveIosVideoOutputPreset(preset: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosVideoOutputPresetKey), preset)?.apply() + } + + actual fun loadIosToneMappingMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosToneMappingModeKey), null) + + actual fun saveIosToneMappingMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosToneMappingModeKey), mode)?.apply() + } + + actual fun loadIosTargetPrimaries(): String? = + preferences?.getString(ProfileScopedKey.of(iosTargetPrimariesKey), null) + + actual fun saveIosTargetPrimaries(primaries: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetPrimariesKey), primaries)?.apply() + } + + actual fun loadIosTargetTransfer(): String? = + preferences?.getString(ProfileScopedKey.of(iosTargetTransferKey), null) + + actual fun saveIosTargetTransfer(transfer: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosTargetTransferKey), transfer)?.apply() + } + + actual fun loadIosHardwareDecoderMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosHardwareDecoderModeKey), null) + + actual fun saveIosHardwareDecoderMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply() + } + + private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(keyBase) + if (sharedPreferences.contains(key)) sharedPreferences.getBoolean(key, defaultValue) else null + } + + private fun saveIosBoolean(keyBase: String, enabled: Boolean) { + preferences?.edit()?.putBoolean(ProfileScopedKey.of(keyBase), enabled)?.apply() + } + + private fun loadIosInt(keyBase: String): Int? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(keyBase) + if (sharedPreferences.contains(key)) sharedPreferences.getInt(key, 0) else null + } + + private fun saveIosInt(keyBase: String, value: Int) { + preferences?.edit()?.putInt(ProfileScopedKey.of(keyBase), value)?.apply() + } + + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = + loadIosBoolean(iosExtendedDynamicRangeEnabledKey, true) + + actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) { + saveIosBoolean(iosExtendedDynamicRangeEnabledKey, enabled) + } + + actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = + loadIosBoolean(iosTargetColorspaceHintEnabledKey, true) + + actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) { + saveIosBoolean(iosTargetColorspaceHintEnabledKey, enabled) + } + + actual fun loadIosHdrComputePeakEnabled(): Boolean? = + loadIosBoolean(iosHdrComputePeakEnabledKey, true) + + actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) { + saveIosBoolean(iosHdrComputePeakEnabledKey, enabled) + } + + actual fun loadIosDebandEnabled(): Boolean? = + loadIosBoolean(iosDebandEnabledKey, false) + + actual fun saveIosDebandEnabled(enabled: Boolean) { + saveIosBoolean(iosDebandEnabledKey, enabled) + } + + actual fun loadIosInterpolationEnabled(): Boolean? = + loadIosBoolean(iosInterpolationEnabledKey, false) + + actual fun saveIosInterpolationEnabled(enabled: Boolean) { + saveIosBoolean(iosInterpolationEnabledKey, enabled) + } + + actual fun loadIosBrightness(): Int? = loadIosInt(iosBrightnessKey) + + actual fun saveIosBrightness(value: Int) { + saveIosInt(iosBrightnessKey, value) + } + + actual fun loadIosContrast(): Int? = loadIosInt(iosContrastKey) + + actual fun saveIosContrast(value: Int) { + saveIosInt(iosContrastKey, value) + } + + actual fun loadIosSaturation(): Int? = loadIosInt(iosSaturationKey) + + actual fun saveIosSaturation(value: Int) { + saveIosInt(iosSaturationKey, value) + } + + actual fun loadIosGamma(): Int? = loadIosInt(iosGammaKey) + + actual fun saveIosGamma(value: Int) { + saveIosInt(iosGammaKey, value) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } @@ -688,6 +830,20 @@ actual object PlayerSettingsStorage { loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -732,5 +888,19 @@ actual object PlayerSettingsStorage { payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt new file mode 100644 index 00000000..adb17d13 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -0,0 +1,317 @@ +package com.nuvio.app.features.player + +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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +@Composable +internal fun IosVideoSettingsModal( + visible: Boolean, + settings: PlayerSettingsUiState, + onSettingsChanged: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)), + ) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onDismiss, + ) + .background(colorScheme.scrim.copy(alpha = 0.56f)), + contentAlignment = Alignment.Center, + ) { + val maxH = maxHeight + AnimatedVisibility( + visible = visible, + enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), + exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + ) { + Column( + modifier = Modifier + .widthIn(max = 460.dp) + .fillMaxWidth(0.92f) + .heightIn(max = maxH * 0.95f) + .clip(RoundedCornerShape(24.dp)) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = {}, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Video", + color = colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + PlayerSettingsRepository.resetIosVideoOutputTuning() + onSettingsChanged() + }) { + Text("Reset tuning") + } + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + OptionGroup( + title = "Output preset", + options = IosVideoOutputPreset.entries, + selected = settings.iosVideoOutputPreset, + label = { it.label }, + description = { it.description }, + onSelect = { + PlayerSettingsRepository.setIosVideoOutputPreset(it) + onSettingsChanged() + }, + ) + + ToggleRow( + title = "HDR peak detection", + description = "Estimate HDR peak brightness when metadata is bad or missing.", + checked = settings.iosHdrComputePeakEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) + onSettingsChanged() + }, + ) + + OptionGroup( + title = "Tone mapping", + options = IosToneMappingMode.entries, + selected = settings.iosToneMappingMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosToneMappingMode(it) + onSettingsChanged() + }, + ) + + ToggleRow( + title = "Deband", + description = "Reduce color banding at a small performance cost.", + checked = settings.iosDebandEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosDebandEnabled(it) + onSettingsChanged() + }, + ) + ToggleRow( + title = "Frame interpolation", + description = "Smooth motion when mpv can use display sync cleanly.", + checked = settings.iosInterpolationEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosInterpolationEnabled(it) + onSettingsChanged() + }, + ) + + PictureSlider( + title = "Brightness", + value = settings.iosBrightness, + onValueChanged = { + PlayerSettingsRepository.setIosBrightness(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Contrast", + value = settings.iosContrast, + onValueChanged = { + PlayerSettingsRepository.setIosContrast(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Saturation", + value = settings.iosSaturation, + onValueChanged = { + PlayerSettingsRepository.setIosSaturation(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = "Gamma", + value = settings.iosGamma, + onValueChanged = { + PlayerSettingsRepository.setIosGamma(it) + onSettingsChanged() + }, + ) + } + } + } + } + } +} + +@Composable +private fun ToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text(text = title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) + Text(text = description, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun PictureSlider( + title: String, + value: Int, + onValueChanged: (Int) -> Unit, +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text(text = value.toString(), color = MaterialTheme.colorScheme.primary) + } + Slider( + value = value.toFloat(), + onValueChange = { onValueChanged(it.roundToInt().coerceIn(-50, 50)) }, + valueRange = -50f..50f, + steps = 99, + ) + } +} + +@Composable +private fun OptionGroup( + title: String, + options: List, + selected: T, + label: (T) -> String, + description: ((T) -> String)? = null, + onSelect: (T) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + options.forEach { option -> + val isSelected = option == selected + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) }, + color = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + }, + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = label(option), color = MaterialTheme.colorScheme.onSurface) + val subtitle = description?.invoke(option) + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + } + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 540ed57b..4f6fff95 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -83,6 +84,7 @@ internal fun PlayerControlsShell( onSpeedClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, + onVideoSettingsClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, onSubmitIntroClick: (() -> Unit)? = null, @@ -182,6 +184,7 @@ internal fun PlayerControlsShell( onSpeedClick = onSpeedClick, onSubtitleClick = onSubtitleClick, onAudioClick = onAudioClick, + onVideoSettingsClick = onVideoSettingsClick, onSourcesClick = onSourcesClick, onEpisodesClick = onEpisodesClick, modifier = Modifier @@ -469,6 +472,7 @@ private fun ProgressControls( onSpeedClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, + onVideoSettingsClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, modifier: Modifier = Modifier, @@ -538,6 +542,13 @@ private fun ProgressControls( painter = audioPainter, onClick = onAudioClick, ) + if (onVideoSettingsClick != null) { + PlayerActionPillButton( + label = "Video", + icon = Icons.Rounded.Tune, + onClick = onVideoSettingsClick, + ) + } if (onSourcesClick != null) { PlayerActionPillButton( label = stringResource(Res.string.compose_player_sources), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8a5b6730..ac0be69f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -18,6 +18,7 @@ interface PlayerEngineController { fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) fun applySubtitleStyle(style: SubtitleStyleState) {} + fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index f659084a..773a276d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -62,6 +62,73 @@ enum class PlayerResizeMode { Zoom, } +enum class IosVideoOutputPreset( + val label: String, + val description: String, +) { + NativeEdr( + label = "Native EDR", + description = "Best for HDR-capable iPhones and iPads.", + ), + SdrToneMapped( + label = "SDR tone mapped", + description = "More predictable whites and blacks on SDR-style output.", + ), + Compatibility( + label = "Compatibility", + description = "Closest to the older iOS MPV behavior.", + ), + Custom( + label = "Custom", + description = "Use your advanced values below.", + ), +} + +enum class IosToneMappingMode( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Bt2390("bt.2390", "BT.2390"), + Mobius("mobius", "Mobius"), + Reinhard("reinhard", "Reinhard"), + Hable("hable", "Hable"), + Gamma("gamma", "Gamma"), + Clip("clip", "Clip"), +} + +enum class IosTargetPrimaries( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Bt709("bt.709", "BT.709"), + DisplayP3("display-p3", "Display P3"), + Bt2020("bt.2020", "BT.2020"), +} + +enum class IosTargetTransfer( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + Srgb("srgb", "sRGB"), + Bt1886("bt.1886", "BT.1886"), + Gamma22("gamma2.2", "Gamma 2.2"), + Gamma24("gamma2.4", "Gamma 2.4"), + Pq("pq", "PQ"), + Hlg("hlg", "HLG"), +} + +enum class IosHardwareDecoderMode( + val mpvValue: String, + val label: String, +) { + Auto("auto", "Auto"), + VideoToolbox("videotoolbox", "VideoToolbox"), + Off("no", "Off"), +} + data class PlayerPlaybackSnapshot( val isLoading: Boolean = true, val isPlaying: Boolean = false, 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 34ac6e92..476b0a77 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 @@ -68,6 +68,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.isIos import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -472,6 +473,7 @@ fun PlayerScreen( var showAudioModal by remember { mutableStateOf(false) } var showSubtitleModal by remember { mutableStateOf(false) } + var showVideoSettingsModal by remember { mutableStateOf(false) } var audioTracks by remember { mutableStateOf>(emptyList()) } var subtitleTracks by remember { mutableStateOf>(emptyList()) } var selectedAudioIndex by remember { mutableStateOf(-1) } @@ -609,6 +611,7 @@ fun PlayerScreen( renderedGestureFeedback = null showAudioModal = false showSubtitleModal = false + showVideoSettingsModal = false showSourcesPanel = false showEpisodesPanel = false episodeStreamsPanelState = EpisodeStreamsPanelState() @@ -1805,6 +1808,14 @@ fun PlayerScreen( refreshTracks() showAudioModal = true }, + onVideoSettingsClick = if (isIos) { + { + showVideoSettingsModal = true + controlsVisible = true + } + } else { + null + }, onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, @@ -1973,6 +1984,15 @@ fun PlayerScreen( onDismiss = { showSubtitleModal = false }, ) + IosVideoSettingsModal( + visible = showVideoSettingsModal, + settings = playerSettingsUiState, + onSettingsChanged = { + playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) + }, + onDismiss = { showVideoSettingsModal = false }, + ) + // Sources Panel PlayerSourcesPanel( visible = showSourcesPanel, 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 15f4f4d7..1c5cd8c7 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 @@ -43,6 +43,20 @@ data class PlayerSettingsUiState( val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, val useLibass: Boolean = false, val libassRenderType: String = "CUES", + val iosVideoOutputPreset: IosVideoOutputPreset = IosVideoOutputPreset.NativeEdr, + val iosToneMappingMode: IosToneMappingMode = IosToneMappingMode.Auto, + val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto, + val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto, + val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.Auto, + val iosExtendedDynamicRangeEnabled: Boolean = true, + val iosTargetColorspaceHintEnabled: Boolean = true, + val iosHdrComputePeakEnabled: Boolean = true, + val iosDebandEnabled: Boolean = false, + val iosInterpolationEnabled: Boolean = false, + val iosBrightness: Int = 0, + val iosContrast: Int = 0, + val iosSaturation: Int = 0, + val iosGamma: Int = 0, ) object PlayerSettingsRepository { @@ -84,6 +98,20 @@ object PlayerSettingsRepository { private var nextEpisodeThresholdMinutesBeforeEnd = 2f private var useLibass = false private var libassRenderType = "CUES" + private var iosVideoOutputPreset = IosVideoOutputPreset.NativeEdr + private var iosToneMappingMode = IosToneMappingMode.Auto + private var iosTargetPrimaries = IosTargetPrimaries.Auto + private var iosTargetTransfer = IosTargetTransfer.Auto + private var iosHardwareDecoderMode = IosHardwareDecoderMode.Auto + private var iosExtendedDynamicRangeEnabled = true + private var iosTargetColorspaceHintEnabled = true + private var iosHdrComputePeakEnabled = true + private var iosDebandEnabled = false + private var iosInterpolationEnabled = false + private var iosBrightness = 0 + private var iosContrast = 0 + private var iosSaturation = 0 + private var iosGamma = 0 fun ensureLoaded() { if (hasLoaded) return @@ -130,6 +158,20 @@ object PlayerSettingsRepository { nextEpisodeThresholdMinutesBeforeEnd = 2f useLibass = false libassRenderType = "CUES" + iosVideoOutputPreset = IosVideoOutputPreset.NativeEdr + iosToneMappingMode = IosToneMappingMode.Auto + iosTargetPrimaries = IosTargetPrimaries.Auto + iosTargetTransfer = IosTargetTransfer.Auto + iosHardwareDecoderMode = IosHardwareDecoderMode.Auto + iosExtendedDynamicRangeEnabled = true + iosTargetColorspaceHintEnabled = true + iosHdrComputePeakEnabled = true + iosDebandEnabled = false + iosInterpolationEnabled = false + iosBrightness = 0 + iosContrast = 0 + iosSaturation = 0 + iosGamma = 0 publish() } @@ -204,6 +246,30 @@ object PlayerSettingsRepository { nextEpisodeThresholdMinutesBeforeEnd = PlayerSettingsStorage.loadNextEpisodeThresholdMinutesBeforeEnd() ?: 2f useLibass = PlayerSettingsStorage.loadUseLibass() ?: false libassRenderType = PlayerSettingsStorage.loadLibassRenderType() ?: "CUES" + iosVideoOutputPreset = PlayerSettingsStorage.loadIosVideoOutputPreset() + ?.let { runCatching { IosVideoOutputPreset.valueOf(it) }.getOrNull() } + ?: IosVideoOutputPreset.NativeEdr + iosToneMappingMode = PlayerSettingsStorage.loadIosToneMappingMode() + ?.let { runCatching { IosToneMappingMode.valueOf(it) }.getOrNull() } + ?: IosToneMappingMode.Auto + iosTargetPrimaries = PlayerSettingsStorage.loadIosTargetPrimaries() + ?.let { runCatching { IosTargetPrimaries.valueOf(it) }.getOrNull() } + ?: IosTargetPrimaries.Auto + iosTargetTransfer = PlayerSettingsStorage.loadIosTargetTransfer() + ?.let { runCatching { IosTargetTransfer.valueOf(it) }.getOrNull() } + ?: IosTargetTransfer.Auto + iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode() + ?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() } + ?: IosHardwareDecoderMode.Auto + iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true + iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true + iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true + iosDebandEnabled = PlayerSettingsStorage.loadIosDebandEnabled() ?: false + iosInterpolationEnabled = PlayerSettingsStorage.loadIosInterpolationEnabled() ?: false + iosBrightness = PlayerSettingsStorage.loadIosBrightness() ?: 0 + iosContrast = PlayerSettingsStorage.loadIosContrast() ?: 0 + iosSaturation = PlayerSettingsStorage.loadIosSaturation() ?: 0 + iosGamma = PlayerSettingsStorage.loadIosGamma() ?: 0 publish() } @@ -498,6 +564,164 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveLibassRenderType(renderType) } + fun setIosVideoOutputPreset(preset: IosVideoOutputPreset) { + ensureLoaded() + iosVideoOutputPreset = preset + when (preset) { + IosVideoOutputPreset.NativeEdr -> { + iosExtendedDynamicRangeEnabled = true + iosTargetColorspaceHintEnabled = true + iosHdrComputePeakEnabled = true + iosToneMappingMode = IosToneMappingMode.Auto + iosTargetPrimaries = IosTargetPrimaries.Auto + iosTargetTransfer = IosTargetTransfer.Auto + } + IosVideoOutputPreset.SdrToneMapped -> { + iosExtendedDynamicRangeEnabled = false + iosTargetColorspaceHintEnabled = false + iosHdrComputePeakEnabled = true + iosToneMappingMode = IosToneMappingMode.Bt2390 + iosTargetPrimaries = IosTargetPrimaries.Bt709 + iosTargetTransfer = IosTargetTransfer.Srgb + } + IosVideoOutputPreset.Compatibility -> { + iosExtendedDynamicRangeEnabled = false + iosTargetColorspaceHintEnabled = true + iosHdrComputePeakEnabled = false + iosToneMappingMode = IosToneMappingMode.Auto + iosTargetPrimaries = IosTargetPrimaries.Auto + iosTargetTransfer = IosTargetTransfer.Auto + } + IosVideoOutputPreset.Custom -> Unit + } + publish() + saveIosVideoOutputSettings() + } + + fun setIosToneMappingMode(mode: IosToneMappingMode) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosToneMappingMode = mode + publish() + saveIosVideoOutputSettings() + } + + fun setIosTargetPrimaries(primaries: IosTargetPrimaries) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosTargetPrimaries = primaries + publish() + saveIosVideoOutputSettings() + } + + fun setIosTargetTransfer(transfer: IosTargetTransfer) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosTargetTransfer = transfer + publish() + saveIosVideoOutputSettings() + } + + fun setIosHardwareDecoderMode(mode: IosHardwareDecoderMode) { + ensureLoaded() + iosHardwareDecoderMode = mode + publish() + PlayerSettingsStorage.saveIosHardwareDecoderMode(mode.name) + } + + fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosExtendedDynamicRangeEnabled = enabled + publish() + saveIosVideoOutputSettings() + } + + fun setIosTargetColorspaceHintEnabled(enabled: Boolean) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosTargetColorspaceHintEnabled = enabled + publish() + saveIosVideoOutputSettings() + } + + fun setIosHdrComputePeakEnabled(enabled: Boolean) { + ensureLoaded() + iosVideoOutputPreset = IosVideoOutputPreset.Custom + iosHdrComputePeakEnabled = enabled + publish() + saveIosVideoOutputSettings() + } + + fun setIosDebandEnabled(enabled: Boolean) { + ensureLoaded() + iosDebandEnabled = enabled + publish() + PlayerSettingsStorage.saveIosDebandEnabled(enabled) + } + + fun setIosInterpolationEnabled(enabled: Boolean) { + ensureLoaded() + iosInterpolationEnabled = enabled + publish() + PlayerSettingsStorage.saveIosInterpolationEnabled(enabled) + } + + fun setIosBrightness(value: Int) { + ensureLoaded() + iosBrightness = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosBrightness(iosBrightness) + } + + fun setIosContrast(value: Int) { + ensureLoaded() + iosContrast = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosContrast(iosContrast) + } + + fun setIosSaturation(value: Int) { + ensureLoaded() + iosSaturation = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosSaturation(iosSaturation) + } + + fun setIosGamma(value: Int) { + ensureLoaded() + iosGamma = value.coerceIn(-50, 50) + publish() + PlayerSettingsStorage.saveIosGamma(iosGamma) + } + + fun resetIosVideoOutputTuning() { + ensureLoaded() + iosBrightness = 0 + iosContrast = 0 + iosSaturation = 0 + iosGamma = 0 + iosDebandEnabled = false + iosInterpolationEnabled = false + publish() + PlayerSettingsStorage.saveIosBrightness(0) + PlayerSettingsStorage.saveIosContrast(0) + PlayerSettingsStorage.saveIosSaturation(0) + PlayerSettingsStorage.saveIosGamma(0) + PlayerSettingsStorage.saveIosDebandEnabled(false) + PlayerSettingsStorage.saveIosInterpolationEnabled(false) + } + + private fun saveIosVideoOutputSettings() { + PlayerSettingsStorage.saveIosVideoOutputPreset(iosVideoOutputPreset.name) + PlayerSettingsStorage.saveIosToneMappingMode(iosToneMappingMode.name) + PlayerSettingsStorage.saveIosTargetPrimaries(iosTargetPrimaries.name) + PlayerSettingsStorage.saveIosTargetTransfer(iosTargetTransfer.name) + PlayerSettingsStorage.saveIosExtendedDynamicRangeEnabled(iosExtendedDynamicRangeEnabled) + PlayerSettingsStorage.saveIosTargetColorspaceHintEnabled(iosTargetColorspaceHintEnabled) + PlayerSettingsStorage.saveIosHdrComputePeakEnabled(iosHdrComputePeakEnabled) + } + private fun publish() { _uiState.value = PlayerSettingsUiState( showLoadingOverlay = showLoadingOverlay, @@ -534,6 +758,20 @@ object PlayerSettingsRepository { nextEpisodeThresholdMinutesBeforeEnd = nextEpisodeThresholdMinutesBeforeEnd, useLibass = useLibass, libassRenderType = libassRenderType, + iosVideoOutputPreset = iosVideoOutputPreset, + iosToneMappingMode = iosToneMappingMode, + iosTargetPrimaries = iosTargetPrimaries, + iosTargetTransfer = iosTargetTransfer, + iosHardwareDecoderMode = iosHardwareDecoderMode, + iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled, + iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled, + iosHdrComputePeakEnabled = iosHdrComputePeakEnabled, + iosDebandEnabled = iosDebandEnabled, + iosInterpolationEnabled = iosInterpolationEnabled, + iosBrightness = iosBrightness, + iosContrast = iosContrast, + iosSaturation = iosSaturation, + iosGamma = iosGamma, ) } 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 5c3b3756..d36cd301 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 @@ -78,6 +78,34 @@ internal expect object PlayerSettingsStorage { fun saveUseLibass(enabled: Boolean) fun loadLibassRenderType(): String? fun saveLibassRenderType(renderType: String) + fun loadIosVideoOutputPreset(): String? + fun saveIosVideoOutputPreset(preset: String) + fun loadIosToneMappingMode(): String? + fun saveIosToneMappingMode(mode: String) + fun loadIosTargetPrimaries(): String? + fun saveIosTargetPrimaries(primaries: String) + fun loadIosTargetTransfer(): String? + fun saveIosTargetTransfer(transfer: String) + fun loadIosHardwareDecoderMode(): String? + fun saveIosHardwareDecoderMode(mode: String) + fun loadIosExtendedDynamicRangeEnabled(): Boolean? + fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) + fun loadIosTargetColorspaceHintEnabled(): Boolean? + fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) + fun loadIosHdrComputePeakEnabled(): Boolean? + fun saveIosHdrComputePeakEnabled(enabled: Boolean) + fun loadIosDebandEnabled(): Boolean? + fun saveIosDebandEnabled(enabled: Boolean) + fun loadIosInterpolationEnabled(): Boolean? + fun saveIosInterpolationEnabled(enabled: Boolean) + fun loadIosBrightness(): Int? + fun saveIosBrightness(value: Int) + fun loadIosContrast(): Int? + fun saveIosContrast(value: Int) + fun loadIosSaturation(): Int? + fun saveIosSaturation(value: Int) + fun loadIosGamma(): Int? + fun saveIosGamma(value: Int) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 042d592d..83daa6df 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 @@ -55,6 +55,9 @@ import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.ExternalPlayerApp import com.nuvio.app.features.player.ExternalPlayerPlatform +import com.nuvio.app.features.player.IosHardwareDecoderMode +import com.nuvio.app.features.player.IosTargetPrimaries +import com.nuvio.app.features.player.IosTargetTransfer import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.formatPlaybackSpeedLabel @@ -175,6 +178,9 @@ private fun PlaybackSettingsSection( var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } + var showIosHardwareDecoderDialog by remember { mutableStateOf(false) } + var showIosTargetPrimariesDialog by remember { mutableStateOf(false) } + var showIosTargetTransferDialog by remember { mutableStateOf(false) } var showLibassRenderTypeDialog by remember { mutableStateOf(false) } var showAutoPlayModeDialog by remember { mutableStateOf(false) } var showAutoPlaySourceDialog by remember { mutableStateOf(false) } @@ -487,6 +493,52 @@ private fun PlaybackSettingsSection( } } + if (isIos) { + SettingsSection( + title = "iOS video output", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = "Hardware decoder", + description = autoPlayPlayerSettings.iosHardwareDecoderMode.label, + isTablet = isTablet, + onClick = { showIosHardwareDecoderDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = "Extended dynamic range", + description = "Default Metal output mode for new playback sessions.", + checked = autoPlayPlayerSettings.iosExtendedDynamicRangeEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setIosExtendedDynamicRangeEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = "Display color hint", + description = "Let mpv target the active display color space by default.", + checked = autoPlayPlayerSettings.iosTargetColorspaceHintEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setIosTargetColorspaceHintEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Target primaries", + description = autoPlayPlayerSettings.iosTargetPrimaries.label, + isTablet = isTablet, + onClick = { showIosTargetPrimariesDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Target transfer", + description = autoPlayPlayerSettings.iosTargetTransfer.label, + isTablet = isTablet, + onClick = { showIosTargetTransferDialog = true }, + ) + } + } + } + if (!isIos) { SettingsSection( title = stringResource(Res.string.settings_playback_section_subtitle_rendering), @@ -854,6 +906,48 @@ private fun PlaybackSettingsSection( ) } + if (showIosHardwareDecoderDialog) { + IosEnumSelectionDialog( + title = "Hardware decoder", + options = IosHardwareDecoderMode.entries, + selected = autoPlayPlayerSettings.iosHardwareDecoderMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosHardwareDecoderMode(it) + showIosHardwareDecoderDialog = false + }, + onDismiss = { showIosHardwareDecoderDialog = false }, + ) + } + + if (showIosTargetPrimariesDialog) { + IosEnumSelectionDialog( + title = "Target primaries", + options = IosTargetPrimaries.entries, + selected = autoPlayPlayerSettings.iosTargetPrimaries, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosTargetPrimaries(it) + showIosTargetPrimariesDialog = false + }, + onDismiss = { showIosTargetPrimariesDialog = false }, + ) + } + + if (showIosTargetTransferDialog) { + IosEnumSelectionDialog( + title = "Target transfer", + options = IosTargetTransfer.entries, + selected = autoPlayPlayerSettings.iosTargetTransfer, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosTargetTransfer(it) + showIosTargetTransferDialog = false + }, + onDismiss = { showIosTargetTransferDialog = false }, + ) + } + if (showLibassRenderTypeDialog) { LibassRenderTypeDialog( selectedRenderType = libassRenderType, @@ -1318,6 +1412,94 @@ private fun DecoderPriorityDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun IosEnumSelectionDialog( + title: String, + options: List, + selected: T, + label: (T) -> String, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, +) { + 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 = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(option) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label(option), + 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 = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun HoldToSpeedValueDialog( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 4c627dc2..9012a96c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -15,6 +15,21 @@ interface NuvioPlayerBridge { fun seekTo(positionMs: Long) fun seekBy(offsetMs: Long) fun retry() + fun configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Boolean, + toneMapping: String, + hdrComputePeak: Boolean, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Boolean, + deband: Boolean, + interpolation: Boolean, + brightness: Int, + contrast: Int, + saturation: Int, + gamma: Int, + ) fun setPlaybackSpeed(speed: Float) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom fun getAudioTrackCount(): Int diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 2877b04c..733bf162 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -3,11 +3,13 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController +import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.delay @@ -37,6 +39,9 @@ actual fun PlatformPlayerSurface( val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + PlayerSettingsRepository.ensureLoaded() + val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() + val latestPlayerSettings = rememberUpdatedState(playerSettings) val bridge = remember { NuvioPlayerBridgeFactory.create() @@ -71,6 +76,10 @@ actual fun PlatformPlayerSurface( bridge.retry() } + override fun configureIosVideoOutput(settings: PlayerSettingsUiState) { + bridge.applyIosVideoOutputSettings(settings) + } + override fun setPlaybackSpeed(speed: Float) { bridge.setPlaybackSpeed(speed) } @@ -214,6 +223,7 @@ actual fun PlatformPlayerSurface( // Load file and set initial state LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) { + bridge.applyIosVideoOutputSettings(latestPlayerSettings.value) bridge.loadFileWithAudio( sourceUrl, sourceAudioUrl, @@ -242,6 +252,10 @@ actual fun PlatformPlayerSurface( ) } + LaunchedEffect(bridge, playerSettings) { + bridge.applyIosVideoOutputSettings(playerSettings) + } + // Polling for snapshots LaunchedEffect(bridge) { var lastReportedError: String? = null @@ -280,6 +294,24 @@ actual fun PlatformPlayerSurface( ) } +private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) { + configureVideoOutput( + hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue, + targetColorspaceHint = settings.iosTargetColorspaceHintEnabled, + toneMapping = settings.iosToneMappingMode.mpvValue, + hdrComputePeak = settings.iosHdrComputePeakEnabled, + targetPrimaries = settings.iosTargetPrimaries.mpvValue, + targetTransfer = settings.iosTargetTransfer.mpvValue, + extendedDynamicRange = settings.iosExtendedDynamicRangeEnabled, + deband = settings.iosDebandEnabled, + interpolation = settings.iosInterpolationEnabled, + brightness = settings.iosBrightness, + contrast = settings.iosContrast, + saturation = settings.iosSaturation, + gamma = settings.iosGamma, + ) +} + private fun Color.toMpvColorString(): String { val redInt = (red * 255f).toInt().coerceIn(0, 255) val greenInt = (green * 255f).toInt().coerceIn(0, 255) 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 0aedbb30..9e539f5d 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 @@ -54,6 +54,20 @@ actual object PlayerSettingsStorage { private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" private const val useLibassKey = "use_libass" private const val libassRenderTypeKey = "libass_render_type" + private const val iosVideoOutputPresetKey = "ios_video_output_preset" + private const val iosToneMappingModeKey = "ios_tone_mapping_mode" + private const val iosTargetPrimariesKey = "ios_target_primaries" + private const val iosTargetTransferKey = "ios_target_transfer" + private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" + private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" + private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" + private const val iosDebandEnabledKey = "ios_deband_enabled" + private const val iosInterpolationEnabledKey = "ios_interpolation_enabled" + private const val iosBrightnessKey = "ios_brightness" + private const val iosContrastKey = "ios_contrast" + private const val iosSaturationKey = "ios_saturation" + private const val iosGammaKey = "ios_gamma" private val syncKeys = listOf( showLoadingOverlayKey, resizeModeKey, @@ -90,8 +104,42 @@ actual object PlayerSettingsStorage { nextEpisodeThresholdMinutesBeforeEndKey, useLibassKey, libassRenderTypeKey, + iosVideoOutputPresetKey, + iosToneMappingModeKey, + iosTargetPrimariesKey, + iosTargetTransferKey, + iosHardwareDecoderModeKey, + iosExtendedDynamicRangeEnabledKey, + iosTargetColorspaceHintEnabledKey, + iosHdrComputePeakEnabledKey, + iosDebandEnabledKey, + iosInterpolationEnabledKey, + iosBrightnessKey, + iosContrastKey, + iosSaturationKey, + iosGammaKey, ) + private fun loadBoolean(keyBase: String): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(keyBase) + return if (defaults.objectForKey(key) != null) defaults.boolForKey(key) else null + } + + private fun saveBoolean(keyBase: String, enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(keyBase)) + } + + private fun loadInt(keyBase: String): Int? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(keyBase) + return if (defaults.objectForKey(key) != null) defaults.integerForKey(key).toInt() else null + } + + private fun saveInt(keyBase: String, value: Int) { + NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(keyBase)) + } + actual fun loadShowLoadingOverlay(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(showLoadingOverlayKey) @@ -552,6 +600,100 @@ actual object PlayerSettingsStorage { actual fun saveLibassRenderType(renderType: String) {} + actual fun loadIosVideoOutputPreset(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosVideoOutputPresetKey)) + + actual fun saveIosVideoOutputPreset(preset: String) { + NSUserDefaults.standardUserDefaults.setObject(preset, forKey = ProfileScopedKey.of(iosVideoOutputPresetKey)) + } + + actual fun loadIosToneMappingMode(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosToneMappingModeKey)) + + actual fun saveIosToneMappingMode(mode: String) { + NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosToneMappingModeKey)) + } + + actual fun loadIosTargetPrimaries(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosTargetPrimariesKey)) + + actual fun saveIosTargetPrimaries(primaries: String) { + NSUserDefaults.standardUserDefaults.setObject(primaries, forKey = ProfileScopedKey.of(iosTargetPrimariesKey)) + } + + actual fun loadIosTargetTransfer(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosTargetTransferKey)) + + actual fun saveIosTargetTransfer(transfer: String) { + NSUserDefaults.standardUserDefaults.setObject(transfer, forKey = ProfileScopedKey.of(iosTargetTransferKey)) + } + + actual fun loadIosHardwareDecoderMode(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosHardwareDecoderModeKey)) + + actual fun saveIosHardwareDecoderMode(mode: String) { + NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosHardwareDecoderModeKey)) + } + + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = + loadBoolean(iosExtendedDynamicRangeEnabledKey) + + actual fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) { + saveBoolean(iosExtendedDynamicRangeEnabledKey, enabled) + } + + actual fun loadIosTargetColorspaceHintEnabled(): Boolean? = + loadBoolean(iosTargetColorspaceHintEnabledKey) + + actual fun saveIosTargetColorspaceHintEnabled(enabled: Boolean) { + saveBoolean(iosTargetColorspaceHintEnabledKey, enabled) + } + + actual fun loadIosHdrComputePeakEnabled(): Boolean? = + loadBoolean(iosHdrComputePeakEnabledKey) + + actual fun saveIosHdrComputePeakEnabled(enabled: Boolean) { + saveBoolean(iosHdrComputePeakEnabledKey, enabled) + } + + actual fun loadIosDebandEnabled(): Boolean? = + loadBoolean(iosDebandEnabledKey) + + actual fun saveIosDebandEnabled(enabled: Boolean) { + saveBoolean(iosDebandEnabledKey, enabled) + } + + actual fun loadIosInterpolationEnabled(): Boolean? = + loadBoolean(iosInterpolationEnabledKey) + + actual fun saveIosInterpolationEnabled(enabled: Boolean) { + saveBoolean(iosInterpolationEnabledKey, enabled) + } + + actual fun loadIosBrightness(): Int? = loadInt(iosBrightnessKey) + + actual fun saveIosBrightness(value: Int) { + saveInt(iosBrightnessKey, value) + } + + actual fun loadIosContrast(): Int? = loadInt(iosContrastKey) + + actual fun saveIosContrast(value: Int) { + saveInt(iosContrastKey, value) + } + + actual fun loadIosSaturation(): Int? = loadInt(iosSaturationKey) + + actual fun saveIosSaturation(value: Int) { + saveInt(iosSaturationKey, value) + } + + actual fun loadIosGamma(): Int? = loadInt(iosGammaKey) + + actual fun saveIosGamma(value: Int) { + saveInt(iosGammaKey, value) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } @@ -588,6 +730,20 @@ actual object PlayerSettingsStorage { loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + loadIosVideoOutputPreset()?.let { put(iosVideoOutputPresetKey, encodeSyncString(it)) } + loadIosToneMappingMode()?.let { put(iosToneMappingModeKey, encodeSyncString(it)) } + loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } + loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } + loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } + loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } + loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } + loadIosDebandEnabled()?.let { put(iosDebandEnabledKey, encodeSyncBoolean(it)) } + loadIosInterpolationEnabled()?.let { put(iosInterpolationEnabledKey, encodeSyncBoolean(it)) } + loadIosBrightness()?.let { put(iosBrightnessKey, encodeSyncInt(it)) } + loadIosContrast()?.let { put(iosContrastKey, encodeSyncInt(it)) } + loadIosSaturation()?.let { put(iosSaturationKey, encodeSyncInt(it)) } + loadIosGamma()?.let { put(iosGammaKey, encodeSyncInt(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -631,5 +787,19 @@ actual object PlayerSettingsStorage { payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + payload.decodeSyncString(iosVideoOutputPresetKey)?.let(::saveIosVideoOutputPreset) + payload.decodeSyncString(iosToneMappingModeKey)?.let(::saveIosToneMappingMode) + payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) + payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) + payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) + payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) + payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) + payload.decodeSyncBoolean(iosDebandEnabledKey)?.let(::saveIosDebandEnabled) + payload.decodeSyncBoolean(iosInterpolationEnabledKey)?.let(::saveIosInterpolationEnabled) + payload.decodeSyncInt(iosBrightnessKey)?.let(::saveIosBrightness) + payload.decodeSyncInt(iosContrastKey)?.let(::saveIosContrast) + payload.decodeSyncInt(iosSaturationKey)?.let(::saveIosSaturation) + payload.decodeSyncInt(iosGammaKey)?.let(::saveIosGamma) } } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 39ec6e2b..afcdc601 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -28,6 +28,37 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func retry() { playerVC?.retryPlayback() } + func configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Bool, + toneMapping: String, + hdrComputePeak: Bool, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Bool, + deband: Bool, + interpolation: Bool, + brightness: Int32, + contrast: Int32, + saturation: Int32, + gamma: Int32 + ) { + playerVC?.configureVideoOutput( + hardwareDecoder: hardwareDecoder, + targetColorspaceHint: targetColorspaceHint, + toneMapping: toneMapping, + hdrComputePeak: hdrComputePeak, + targetPrimaries: targetPrimaries, + targetTransfer: targetTransfer, + extendedDynamicRange: extendedDynamicRange, + deband: deband, + interpolation: interpolation, + brightness: Int(brightness), + contrast: Int(contrast), + saturation: Int(saturation), + gamma: Int(gamma) + ) + } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } @@ -204,6 +235,7 @@ final class MPVPlayerViewController: UIViewController { metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor + metalLayer.wantsExtendedDynamicRangeContent = true view.layer.addSublayer(metalLayer) layoutMetalLayer() @@ -286,7 +318,7 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "keep-open", "yes")) checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) checkError(mpv_set_option_string(mpv, "tone-mapping", "auto")) - checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "no")) + checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "yes")) checkError(mpv_initialize(mpv)) @@ -435,6 +467,38 @@ final class MPVPlayerViewController: UIViewController { } } + func configureVideoOutput( + hardwareDecoder: String, + targetColorspaceHint: Bool, + toneMapping: String, + hdrComputePeak: Bool, + targetPrimaries: String, + targetTransfer: String, + extendedDynamicRange: Bool, + deband: Bool, + interpolation: Bool, + brightness: Int, + contrast: Int, + saturation: Int, + gamma: Int + ) { + metalLayer.wantsExtendedDynamicRangeContent = extendedDynamicRange + guard mpv != nil else { return } + + setStringProperty("hwdec", hardwareDecoder) + setStringProperty("target-colorspace-hint", targetColorspaceHint ? "yes" : "no") + setStringProperty("tone-mapping", toneMapping) + setStringProperty("hdr-compute-peak", hdrComputePeak ? "yes" : "no") + setStringProperty("target-prim", targetPrimaries) + setStringProperty("target-trc", targetTransfer) + setStringProperty("deband", deband ? "yes" : "no") + setStringProperty("interpolation", interpolation ? "yes" : "no") + setVideoEqualizer("brightness", brightness) + setVideoEqualizer("contrast", contrast) + setVideoEqualizer("saturation", saturation) + setVideoEqualizer("gamma", gamma) + } + func setSpeed(_ speed: Float) { guard mpv != nil else { return } var s = Double(speed) @@ -831,6 +895,12 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_property_string(mpv, name, value)) } + private func setVideoEqualizer(_ name: String, _ value: Int) { + guard mpv != nil else { return } + var clamped = Int64(max(-100, min(100, value))) + checkError(mpv_set_property(mpv, name, MPV_FORMAT_INT64, &clamped)) + } + private func getInt(_ name: String) -> Int { guard mpv != nil else { return 0 } var data = Int64() From ecdf959d1bcc3e4777399cc2a646fa11f5f2ee1e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 21:21:55 +0530 Subject: [PATCH 10/19] chore: adding prefetch warning note --- .../src/commonMain/composeResources/values/strings.xml | 1 + .../com/nuvio/app/features/settings/DebridSettingsPage.kt | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d5112ecd..ddbb9b65 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -605,6 +605,7 @@ Prepare links Resolve the first sources before playback starts. Sources to prepare + Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. 1 source %1$d sources Formatting diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 9c2fdfda..4217812b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -77,6 +77,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count_warning import nuvio.composeapp.generated.resources.settings_debrid_key_invalid import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description @@ -375,6 +376,12 @@ private fun DebridPrepareCountDialog( } } } + + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } From 50f9cdd8eaa995bb46a39e760ddc311ed0cbc4f0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 23:58:01 +0530 Subject: [PATCH 11/19] feat(debrid): adjust template settings for stream name and description --- .../features/settings/DebridSettingsPage.kt | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 4217812b..30d59534 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -45,6 +45,7 @@ import com.nuvio.app.features.debrid.DebridCredentialValidator import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag import com.nuvio.app.features.debrid.DebridStreamEncode @@ -70,6 +71,8 @@ import nuvio.composeapp.generated.resources.settings_debrid_dialog_title import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice +import nuvio.composeapp.generated.resources.settings_debrid_description_template +import nuvio.composeapp.generated.resources.settings_debrid_description_template_description import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_subtitle import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many @@ -79,6 +82,8 @@ import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_play import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count_warning import nuvio.composeapp.generated.resources.settings_debrid_key_invalid +import nuvio.composeapp.generated.resources.settings_debrid_name_template +import nuvio.composeapp.generated.resources.settings_debrid_name_template_description import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback @@ -278,11 +283,31 @@ internal fun LazyListScope.debridSettingsContent( } item { + var activeTemplateField by rememberSaveable { mutableStateOf(null) } + SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + value = templatePreview(settings.streamNameTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.NAME }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + value = templatePreview(settings.streamDescriptionTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, + ) + SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_formatter_reset_title), @@ -293,9 +318,43 @@ internal fun LazyListScope.debridSettingsContent( ) } } + + when (activeTemplateField) { + DebridTemplateField.NAME -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + currentValue = settings.streamNameTemplate, + defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE, + onSave = DebridSettingsRepository::setStreamNameTemplate, + onDismiss = { activeTemplateField = null }, + ) + DebridTemplateField.DESCRIPTION -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + currentValue = settings.streamDescriptionTemplate, + defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + onSave = DebridSettingsRepository::setStreamDescriptionTemplate, + onDismiss = { activeTemplateField = null }, + ) + null -> Unit + } } } +private enum class DebridTemplateField { + NAME, + DESCRIPTION, +} + +private fun templatePreview(value: String): String { + val firstLine = value + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + ?: return "" + return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." +} + @Composable private fun prepareCountLabel(limit: Int): String = if (limit == 1) { @@ -387,6 +446,78 @@ private fun DebridPrepareCountDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTemplateDialog( + title: String, + description: String, + currentValue: String, + defaultValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 140.dp, max = 280.dp), + minLines = 5, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton(onClick = { draft = defaultValue }) { + Text( + text = stringResource(Res.string.action_reset), + maxLines = 1, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.action_cancel), + maxLines = 1, + ) + } + Button( + onClick = { + onSave(draft) + onDismiss() + }, + ) { + Text( + text = stringResource(Res.string.action_save), + maxLines = 1, + ) + } + } + } + } + } +} + @Composable private fun DebridPreferenceRow( isTablet: Boolean, From 507d84209888c59acdd74433c7092195822d5a07 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 00:06:58 +0530 Subject: [PATCH 12/19] feat: cahnge display order of debrid links --- .../player/PlayerStreamsRepository.kt | 23 ++++-- .../streams/StreamAutoPlaySelector.kt | 28 ++++++++ .../app/features/streams/StreamsRepository.kt | 70 +++++++++++-------- 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 6e9487ed..013460c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginScraper import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamsUiState @@ -21,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -199,7 +201,8 @@ object PlayerStreamsRepository { return } - val initialGroups = streamAddons.map { addon -> + val installedAddonOrder = streamAddons.map { it.addonName } + val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, @@ -220,7 +223,7 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) - } + }, installedAddonOrder) stateFlow.value = StreamsUiState( groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), @@ -299,11 +302,20 @@ object PlayerStreamsRepository { } val jobs = addonJobs + pluginJobs + debridJobs - var debridPreparationLaunched = false + val completions = Channel(capacity = Channel.BUFFERED) jobs.forEach { deferred -> - val result = deferred.await() + launch { + completions.send(deferred.await()) + } + } + var debridPreparationLaunched = false + repeat(jobs.size) { + val result = completions.receive() stateFlow.update { current -> - val updated = current.groups.map { g -> if (g.addonId == result.addonId) result else g } + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { g -> if (g.addonId == result.addonId) result else g }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -340,6 +352,7 @@ object PlayerStreamsRepository { } } } + completions.close() } setJob(job) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 5fbfb6fc..5917325a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -2,6 +2,34 @@ package com.nuvio.app.features.streams object StreamAutoPlaySelector { + fun orderAddonStreams( + groups: List, + installedOrder: List, + ): List { + if (groups.isEmpty()) return groups + + val addonRankByName = HashMap(installedOrder.size) + installedOrder.forEachIndexed { index, addonName -> + if (addonName !in addonRankByName) { + addonRankByName[addonName] = index + } + } + + val (directDebridEntries, remainingEntries) = groups.partition { group -> + group.addonId.startsWith("debrid:") || + group.streams.any { stream -> stream.isDirectDebridStream } + } + if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries + + val (addonEntries, pluginEntries) = remainingEntries.partition { group -> + group.addonName in addonRankByName + } + val orderedAddons = addonEntries.sortedBy { group -> + addonRankByName.getValue(group.addonName) + } + return directDebridEntries + orderedAddons + pluginEntries + } + fun selectAutoPlayStream( streams: List, mode: StreamAutoPlayMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 1f7d42e1..2fc87a24 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -184,7 +184,8 @@ object StreamsRepository { } // Initialise loading placeholders - val initialGroups = streamAddons.map { addon -> + val installedAddonOrder = streamAddons.map { it.addonName } + val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, @@ -205,7 +206,7 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) - } + }, installedAddonOrder) _uiState.value = StreamsUiState( requestToken = requestToken, groups = initialGroups, @@ -226,9 +227,7 @@ object StreamsRepository { pluginProviderGroups.sumOf { it.scrapers.size } + debridTargets.size - val installedAddonNames = installedAddons - .map { it.displayTitle } - .toSet() + val installedAddonNames = installedAddonOrder.toSet() var autoSelectTriggered = false var timeoutElapsed = false var debridPreparationLaunched = false @@ -383,9 +382,12 @@ object StreamsRepository { is StreamLoadCompletion.Addon -> { val result = completion.group _uiState.update { current -> - val updated = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - } + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -403,28 +405,31 @@ object StreamsRepository { } _uiState.update { current -> - val updated = current.groups.map { group -> - if (group.addonId != completion.addonId) { - group - } else { - val mergedStreams = if (completion.streams.isEmpty()) { - group.streams + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId != completion.addonId) { + group } else { - (group.streams + completion.streams).sortedForGroupedDisplay() + val mergedStreams = if (completion.streams.isEmpty()) { + group.streams + } else { + (group.streams + completion.streams).sortedForGroupedDisplay() + } + val stillLoading = remaining > 0 + val finalError = if (mergedStreams.isEmpty() && !stillLoading) { + pluginFirstErrorByAddonId[completion.addonId] + } else { + null + } + group.copy( + streams = mergedStreams, + isLoading = stillLoading, + error = finalError, + ) } - val stillLoading = remaining > 0 - val finalError = if (mergedStreams.isEmpty() && !stillLoading) { - pluginFirstErrorByAddonId[completion.addonId] - } else { - null - } - group.copy( - streams = mergedStreams, - isLoading = stillLoading, - error = finalError, - ) - } - } + }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, @@ -437,9 +442,12 @@ object StreamsRepository { is StreamLoadCompletion.Debrid -> { val result = completion.group _uiState.update { current -> - val updated = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - } + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + }, + installedOrder = installedAddonOrder, + ) val anyLoading = updated.any { it.isLoading } current.copy( groups = updated, From c18916e6e3a998b5d3bfdd03abc1046d363ffb9a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 00:14:58 +0530 Subject: [PATCH 13/19] feat: adding watched markers to remaining places --- .../nuvio/app/core/ui/NuvioPosterActionSheet.kt | 16 ++++++++++++++++ .../nuvio/app/core/ui/NuvioShelfComponents.kt | 7 +------ .../nuvio/app/features/catalog/CatalogScreen.kt | 13 +++++++++++++ .../features/collection/FolderDetailScreen.kt | 16 ++++++++++++++++ .../app/features/details/PersonDetailScreen.kt | 14 +++++++++++--- .../features/details/TmdbEntityBrowseScreen.kt | 10 +++++++++- .../nuvio/app/features/library/LibraryScreen.kt | 15 ++++++++++++++- .../app/features/search/SearchDiscoverContent.kt | 9 ++------- 8 files changed, 82 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index e226f637..86520e0a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -32,6 +33,7 @@ import androidx.compose.ui.draw.clip 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.dp import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay @@ -151,6 +153,20 @@ fun NuvioAnimatedWatchedBadge( } } +@Composable +fun BoxScope.NuvioPosterWatchedOverlay( + isWatched: Boolean, + modifier: Modifier = Modifier, + padding: Dp = 6.dp, +) { + NuvioAnimatedWatchedBadge( + isVisible = isWatched, + modifier = modifier + .align(Alignment.TopEnd) + .padding(padding), + ) +} + @Composable private fun PosterSheetHeader( item: MetaPreview, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index ace10d77..b0cf02fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -179,12 +179,7 @@ fun NuvioPosterCard( } } - NuvioAnimatedWatchedBadge( - isVisible = isWatched, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(6.dp), - ) + NuvioPosterWatchedOverlay(isWatched = isWatched) } if (shouldShowTitleBelow) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index c611b161..e068aa4e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -47,6 +47,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding @@ -55,6 +56,8 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -79,6 +82,10 @@ fun CatalogScreen( val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val posterCardStyle = rememberPosterCardStyleUiState() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } @@ -187,6 +194,10 @@ fun CatalogScreen( item = item, cornerRadiusDp = posterCardStyle.cornerRadiusDp, hideLabels = posterCardStyle.hideLabelsEnabled, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedUiState.watchedKeys, + item = item, + ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, ) @@ -258,6 +269,7 @@ private fun CatalogPosterTile( item: MetaPreview, cornerRadiusDp: Int, hideLabels: Boolean, + isWatched: Boolean, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, ) { @@ -280,6 +292,7 @@ private fun CatalogPosterTile( contentScale = ContentScale.Crop, ) } + NuvioPosterWatchedOverlay(isWatched = isWatched) } if (!hideLabels) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 6101d18a..5881ce2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -61,6 +61,8 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.canOpenCatalog import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.components.HomeCatalogRowSection +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -79,6 +81,10 @@ fun FolderDetailScreen( onPosterClick: (MetaPreview) -> Unit, ) { val uiState by FolderDetailRepository.uiState.collectAsState() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsState() val folder = uiState.folder val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() } val density = LocalDensity.current @@ -160,18 +166,21 @@ fun FolderDetailScreen( when (uiState.viewMode) { FolderViewMode.TABBED_GRID -> TabbedGridContent( uiState = uiState, + watchedKeys = watchedUiState.watchedKeys, modifier = Modifier.weight(1f).then(contentModifier), onTabSelected = { FolderDetailRepository.selectTab(it) }, onPosterClick = onPosterClick, ) FolderViewMode.ROWS -> RowsContent( uiState = uiState, + watchedKeys = watchedUiState.watchedKeys, modifier = Modifier.weight(1f).then(contentModifier), onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, ) FolderViewMode.FOLLOW_LAYOUT -> RowsContent( uiState = uiState, + watchedKeys = watchedUiState.watchedKeys, modifier = Modifier.weight(1f).then(contentModifier), onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, @@ -199,6 +208,7 @@ private fun FolderCoverImage( @Composable private fun TabbedGridContent( uiState: FolderDetailUiState, + watchedKeys: Set, modifier: Modifier = Modifier, onTabSelected: (Int) -> Unit, onPosterClick: (MetaPreview) -> Unit, @@ -285,6 +295,10 @@ private fun TabbedGridContent( imageUrl = item.poster, shape = NuvioPosterShape.Poster, detailLine = item.releaseInfo, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + ), onClick = { onPosterClick(item) }, ) } @@ -304,6 +318,7 @@ private fun TabbedGridContent( @Composable private fun RowsContent( uiState: FolderDetailUiState, + watchedKeys: Set, modifier: Modifier = Modifier, onCatalogClick: (HomeCatalogSection) -> Unit, onPosterClick: (MetaPreview) -> Unit, @@ -340,6 +355,7 @@ private fun RowsContent( } else { null }, + watchedKeys = watchedKeys, onPosterClick = { onPosterClick(it) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 769456b6..f209f7f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest @@ -63,6 +64,7 @@ import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.tmdb.TmdbMetadataService +import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CurrentDateProvider import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -89,6 +91,10 @@ fun PersonDetailScreen( modifier: Modifier = Modifier, ) { var uiState by remember(personId) { mutableStateOf(PersonDetailUiState.Loading) } + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val resolvedAvatarTransitionKey = avatarTransitionKey ?: castAvatarSharedTransitionKey(personId) LaunchedEffect(personId) { @@ -127,6 +133,7 @@ fun PersonDetailScreen( ) is PersonDetailUiState.Success -> PersonDetailContent( person = state.personDetail, + watchedKeys = watchedUiState.watchedKeys, onOpenMeta = onOpenMeta, initialProfilePhoto = initialProfilePhoto, avatarTransitionKey = resolvedAvatarTransitionKey, @@ -156,6 +163,7 @@ fun PersonDetailScreen( @OptIn(ExperimentalSharedTransitionApi::class) private fun PersonDetailContent( person: PersonDetail, + watchedKeys: Set, onOpenMeta: (MetaPreview) -> Unit, initialProfilePhoto: String? = null, avatarTransitionKey: String, @@ -274,7 +282,7 @@ private fun PersonDetailContent( DetailPosterRailSection( title = stringResource(Res.string.person_popular), items = popularCredits, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) @@ -285,7 +293,7 @@ private fun PersonDetailContent( DetailPosterRailSection( title = stringResource(Res.string.person_latest), items = latestCredits, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) @@ -296,7 +304,7 @@ private fun PersonDetailContent( DetailPosterRailSection( title = stringResource(Res.string.person_upcoming), items = upcomingCredits, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 2c03a8fe..04abb4ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -42,6 +42,7 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -55,6 +56,7 @@ import com.nuvio.app.features.tmdb.TmdbEntityKind import com.nuvio.app.features.tmdb.TmdbEntityMediaType import com.nuvio.app.features.tmdb.TmdbEntityRailType import com.nuvio.app.features.tmdb.TmdbMetadataService +import com.nuvio.app.features.watched.WatchedRepository private sealed interface EntityBrowseUiState { data object Loading : EntityBrowseUiState @@ -75,6 +77,10 @@ fun TmdbEntityBrowseScreen( var uiState by remember(entityKind, entityId) { mutableStateOf(EntityBrowseUiState.Loading) } + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName) LaunchedEffect(entityKind, entityId) { @@ -106,6 +112,7 @@ fun TmdbEntityBrowseScreen( is EntityBrowseUiState.Success -> EntityBrowseContent( data = state.data, sourceType = sourceType, + watchedKeys = watchedUiState.watchedKeys, onOpenMeta = onOpenMeta, ) } @@ -131,6 +138,7 @@ fun TmdbEntityBrowseScreen( private fun EntityBrowseContent( data: TmdbEntityBrowseData, sourceType: String, + watchedKeys: Set, onOpenMeta: (MetaPreview) -> Unit, ) { val backgroundUrl = remember(data.rails, sourceType) { @@ -208,7 +216,7 @@ private fun EntityBrowseContent( DetailPosterRailSection( title = railTitle, items = rail.items, - watchedKeys = emptySet(), + watchedKeys = watchedKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index dc75e101..863fa3b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -32,6 +32,8 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch @@ -50,6 +52,10 @@ fun LibraryScreen( LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() @@ -176,6 +182,7 @@ fun LibraryScreen( else -> { librarySections( sections = uiState.sections, + watchedKeys = watchedUiState.watchedKeys, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, onPosterLongClick = onPosterLongClick, @@ -187,6 +194,7 @@ fun LibraryScreen( private fun LazyListScope.librarySections( sections: List, + watchedKeys: Set, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, @@ -209,8 +217,13 @@ private fun LazyListScope.librarySections( viewAllPillSize = NuvioViewAllPillSize.Compact, key = { item -> "${item.type}:${item.id}" }, ) { item -> + val posterItem = item.toMetaPreview() HomePosterCard( - item = item.toMetaPreview(), + item = posterItem, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = posterItem, + ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item, section) } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 5648e096..3f1901f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -50,12 +50,12 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.home.MetaPreview @@ -404,12 +404,7 @@ private fun DiscoverPosterTile( contentScale = ContentScale.Crop, ) } - NuvioAnimatedWatchedBadge( - isVisible = isWatched, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(6.dp), - ) + NuvioPosterWatchedOverlay(isWatched = isWatched) } if (!hideLabels) { Text( From 13328a782f85a89bed595073582496850bb3a631 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 01:13:31 +0530 Subject: [PATCH 14/19] feat: implement collection hero fallback mechanism in HomeRepository --- .../home/HomeCatalogSettingsRepository.kt | 1 + .../nuvio/app/features/home/HomeRepository.kt | 210 +++++++++++++++++- 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 202af87a..c5bd40ad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -145,6 +145,7 @@ object HomeCatalogSettingsRepository { enforcePinnedCollectionsAtTop() publish() persist() + HomeRepository.applyCurrentSettings() } internal fun snapshot(): HomeCatalogSettingsSnapshot { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 4573db3c..083355a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -1,7 +1,14 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.fetchCatalogPage +import com.nuvio.app.features.collection.Collection +import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.collection.CollectionSource +import com.nuvio.app.features.collection.TmdbCollectionSourceResolver +import com.nuvio.app.features.collection.findCollectionCatalog +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,6 +34,10 @@ object HomeRepository { private var lastRequestKey: String? = null private var currentDefinitions: List = emptyList() private var cachedSections: Map = emptyMap() + private var cachedCollectionHeroItems: List = emptyList() + private var collectionHeroJob: Job? = null + private var collectionHeroRequestKey: String? = null + private var lastPublishedCatalogHeroEmpty: Boolean = true private var lastErrorMessage: String? = null fun refresh(addons: List, force: Boolean = false) { @@ -55,10 +66,14 @@ object HomeRepository { activeRequestKey = null cachedSections = emptyMap() lastErrorMessage = null - _uiState.value = HomeUiState( + publishCurrentState( isLoading = false, - sections = emptyList(), - errorMessage = null, + requestKey = requestKey, + ) + ensureCollectionHeroFallback( + addons = addons, + force = force, + requestKey = requestKey, ) return } @@ -119,6 +134,11 @@ object HomeRepository { isLoading = false, requestKey = requestKey, ) + ensureCollectionHeroFallback( + addons = addons, + force = force, + requestKey = requestKey, + ) } } @@ -127,6 +147,11 @@ object HomeRepository { isLoading = _uiState.value.isLoading, requestKey = activeRequestKey ?: lastRequestKey, ) + ensureCollectionHeroFallback( + addons = AddonRepository.uiState.value.addons, + force = false, + requestKey = activeRequestKey ?: lastRequestKey, + ) } fun clear() { @@ -136,6 +161,11 @@ object HomeRepository { lastRequestKey = null currentDefinitions = emptyList() cachedSections = emptyMap() + cachedCollectionHeroItems = emptyList() + collectionHeroJob?.cancel() + collectionHeroJob = null + collectionHeroRequestKey = null + lastPublishedCatalogHeroEmpty = true lastErrorMessage = null _uiState.value = HomeUiState() } @@ -164,7 +194,7 @@ object HomeRepository { ) } - val heroItems = if (snapshot.heroEnabled) { + val catalogHeroItems = if (snapshot.heroEnabled) { val heroRandom = Random((requestKey?.hashCode() ?: 0).absoluteValue + 1) currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } @@ -177,6 +207,12 @@ object HomeRepository { } else { emptyList() } + lastPublishedCatalogHeroEmpty = snapshot.heroEnabled && catalogHeroItems.isEmpty() + val heroItems = if (snapshot.heroEnabled) { + catalogHeroItems.ifEmpty { cachedCollectionHeroItems } + } else { + emptyList() + } _uiState.value = HomeUiState( isLoading = isLoading, @@ -222,9 +258,175 @@ object HomeRepository { supportsPagination = supportsPagination, ) } + + private fun ensureCollectionHeroFallback( + addons: List, + force: Boolean, + requestKey: String?, + ) { + if (!lastPublishedCatalogHeroEmpty) return + val snapshot = HomeCatalogSettingsRepository.snapshot() + if (!snapshot.heroEnabled) return + val collections = enabledCollectionsForHero(snapshot) + if (collections.isEmpty()) { + cachedCollectionHeroItems = emptyList() + collectionHeroRequestKey = null + return + } + + val nextRequestKey = collectionHeroRequestKey( + collections = collections, + addons = addons, + snapshot = snapshot, + requestKey = requestKey, + ) + if (!force && collectionHeroRequestKey == nextRequestKey) return + + collectionHeroJob?.cancel() + collectionHeroRequestKey = nextRequestKey + cachedCollectionHeroItems = emptyList() + publishCurrentState( + isLoading = _uiState.value.isLoading, + requestKey = requestKey, + ) + + collectionHeroJob = scope.launch { + val sources = collectionHeroSources(collections) + val sourceResults = sources.map { source -> + async { + runCatching { + source.resolveCollectionHeroItems(addons) + }.getOrDefault(emptyList()) + } + }.awaitAll() + val random = Random((nextRequestKey.hashCode()).absoluteValue + 7) + cachedCollectionHeroItems = roundRobinCollectionHeroItems(sourceResults) + .distinctBy { item -> item.stableKey() } + .shuffled(random) + .take(HOME_HERO_ITEM_LIMIT) + publishCurrentState( + isLoading = _uiState.value.isLoading, + requestKey = requestKey, + ) + } + } + + private fun enabledCollectionsForHero(snapshot: HomeCatalogSettingsSnapshot): List { + val preferences = snapshot.preferences + return CollectionRepository.collections.value + .filter { collection -> + collection.folders.isNotEmpty() && + preferences["collection_${collection.id}"]?.enabled != false + } + .sortedBy { collection -> + preferences["collection_${collection.id}"]?.order ?: Int.MAX_VALUE + } + } + + private fun collectionHeroSources(collections: List): List = + collections + .flatMap { collection -> collection.folders } + .flatMap { folder -> folder.resolvedSources } + .take(HOME_COLLECTION_HERO_SOURCE_LIMIT) + + private suspend fun CollectionSource.resolveCollectionHeroItems(addons: List): List { + val page = when { + isTmdb -> TmdbCollectionSourceResolver.resolve(source = this, page = 1) + isTrakt -> TraktPublicListSourceResolver.resolve(source = this, page = 1) + else -> { + val catalogSource = addonCatalogSource() ?: return emptyList() + val resolvedCatalog = addons.findCollectionCatalog(catalogSource) ?: return emptyList() + fetchCatalogPage( + manifestUrl = resolvedCatalog.addon.manifestUrl, + type = catalogSource.type, + catalogId = catalogSource.catalogId, + genre = catalogSource.genre, + maxItems = HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT, + ) + } + } + val items = page.items + return if (HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) { + items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + } else { + items + } + } + + private fun roundRobinCollectionHeroItems(sourceResults: List>): List { + val iterators = sourceResults.filter { it.isNotEmpty() }.map { it.iterator() } + if (iterators.isEmpty()) return emptyList() + val merged = mutableListOf() + var hasMore = true + while (hasMore && merged.size < HOME_COLLECTION_HERO_SOURCE_LIMIT * HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT) { + hasMore = false + iterators.forEach { iterator -> + if (iterator.hasNext()) { + merged.add(iterator.next()) + hasMore = true + } + } + } + return merged + } + + private fun collectionHeroRequestKey( + collections: List, + addons: List, + snapshot: HomeCatalogSettingsSnapshot, + requestKey: String?, + ): String = buildString { + append(requestKey.orEmpty()) + append("|hideUnreleased=") + append(snapshot.hideUnreleasedContent) + append("|collections=") + collections.forEach { collection -> + val preference = snapshot.preferences["collection_${collection.id}"] + append(collection.id) + append(":") + append(preference?.order ?: Int.MAX_VALUE) + append(":") + collection.folders.forEach { folder -> + append(folder.id) + append("[") + folder.resolvedSources.forEach { source -> + append(collectionSourceKey(source)) + append(",") + } + append("]") + } + append(";") + } + append("|addons=") + addons.forEach { addon -> + append(addon.manifest?.id.orEmpty()) + append(":") + append(addon.manifestUrl) + append(":") + append(addon.manifest?.catalogs?.size ?: 0) + append(";") + } + } + + private fun collectionSourceKey(source: CollectionSource): String = + listOf( + source.provider, + source.addonId, + source.type, + source.catalogId, + source.genre, + source.tmdbSourceType, + source.tmdbId?.toString(), + source.traktListId?.toString(), + source.mediaType, + source.sortBy, + source.sortHow, + ).joinToString(":") { it.orEmpty() } } private const val HOME_HERO_ITEM_LIMIT = 8 +private const val HOME_COLLECTION_HERO_SOURCE_LIMIT = 6 +private const val HOME_COLLECTION_HERO_SOURCE_ITEM_LIMIT = 8 private const val HOME_CATALOG_FETCH_BATCH_SIZE = 4 private const val HOME_CATALOG_PREVIEW_FETCH_LIMIT = 18 private const val HOME_CATALOG_PUBLISH_INTERVAL = 2 From 9c12ce473f4ca91f67b1a918f6f663764f8d0565 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 01:48:20 +0530 Subject: [PATCH 15/19] ref(ios): minor mpv tuning icon change --- .../app/features/player/PlayerControls.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 4f6fff95..4abec385 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Build import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Forward10 import androidx.compose.material.icons.rounded.Lock @@ -31,7 +32,6 @@ import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Replay10 import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.SwapHoriz -import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -147,6 +147,7 @@ internal fun PlayerControlsShell( showParentalGuide = showParentalGuide, onParentalGuideAnimationComplete = onParentalGuideAnimationComplete, onLockToggle = onLockToggle, + onVideoSettingsClick = onVideoSettingsClick, onBack = onBack, modifier = Modifier .align(Alignment.TopStart) @@ -184,7 +185,6 @@ internal fun PlayerControlsShell( onSpeedClick = onSpeedClick, onSubtitleClick = onSubtitleClick, onAudioClick = onAudioClick, - onVideoSettingsClick = onVideoSettingsClick, onSourcesClick = onSourcesClick, onEpisodesClick = onEpisodesClick, modifier = Modifier @@ -214,6 +214,7 @@ private fun PlayerHeader( showParentalGuide: Boolean, onParentalGuideAnimationComplete: () -> Unit, onLockToggle: () -> Unit, + onVideoSettingsClick: (() -> Unit)?, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -324,6 +325,15 @@ private fun PlayerHeader( iconSize = metrics.headerIconSize, onClick = onLockToggle, ) + if (onVideoSettingsClick != null) { + PlayerHeaderIconButton( + icon = Icons.Rounded.Build, + contentDescription = "Video settings", + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onVideoSettingsClick, + ) + } NuvioBackButton( onClick = onBack, containerColor = Color.Black.copy(alpha = 0.35f), @@ -472,7 +482,6 @@ private fun ProgressControls( onSpeedClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, - onVideoSettingsClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, modifier: Modifier = Modifier, @@ -542,13 +551,6 @@ private fun ProgressControls( painter = audioPainter, onClick = onAudioClick, ) - if (onVideoSettingsClick != null) { - PlayerActionPillButton( - label = "Video", - icon = Icons.Rounded.Tune, - onClick = onVideoSettingsClick, - ) - } if (onSourcesClick != null) { PlayerActionPillButton( label = stringResource(Res.string.compose_player_sources), From d29c1e363a7f675172dc4c4fe743a5a1c27bdfb4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 01:49:32 +0530 Subject: [PATCH 16/19] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 4dfd8633..10b019f6 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=63 -MARKETING_VERSION=0.1.21 +CURRENT_PROJECT_VERSION=64 +MARKETING_VERSION=0.1.22 From 9818458b9faa36ab226f912a02d243ac2443c46e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 01:55:31 +0530 Subject: [PATCH 17/19] remove duplicate string --- .../src/commonMain/composeResources/values-no/strings.xml | 3 +-- stremio-community-v5 | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) create mode 160000 stremio-community-v5 diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 1fa4e846..06b43610 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -1199,7 +1199,6 @@ Fjern %1$s fra biblioteket ditt? Fjern fra bibliotek? Film - Serie Varsler når en ny episode for en lagret serie er ute. Forhåndsvisning av episodeutgivelsesvarsel. Kunne ikke sende testvarsel. @@ -1332,4 +1331,4 @@ KB MB GB - \ No newline at end of file + diff --git a/stremio-community-v5 b/stremio-community-v5 new file mode 160000 index 00000000..da0783df --- /dev/null +++ b/stremio-community-v5 @@ -0,0 +1 @@ +Subproject commit da0783dfd8e067b97a95d11e33c78936f523c4d3 From 5fe7364d5d59e99f74122fe05ff8064bd2eca7d9 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 20 May 2026 13:05:14 +0200 Subject: [PATCH 18/19] Port Reuse Binge Group from the TV version --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../player/PlayerSettingsStorage.android.kt | 21 ++ .../streams/BingeGroupCacheStorage.android.kt | 32 +++ .../composeResources/values-no/strings.xml | 1 - .../composeResources/values-pl/strings.xml | 183 ++++++++++++++ .../composeResources/values/strings.xml | 2 + .../commonMain/kotlin/com/nuvio/app/App.kt | 2 + .../nuvio/app/features/player/PlayerScreen.kt | 233 ++++++++++++++---- .../player/PlayerSettingsRepository.kt | 44 ++++ .../features/player/PlayerSettingsStorage.kt | 2 + .../features/settings/PlaybackSettingsPage.kt | 27 +- .../streams/BingeGroupCacheRepository.kt | 21 ++ .../streams/BingeGroupCacheStorage.kt | 7 + .../features/streams/StreamAutoPlayPolicy.kt | 1 + .../streams/StreamAutoPlaySelector.kt | 9 +- .../app/features/streams/StreamsRepository.kt | 142 +++++++++-- .../app/features/streams/StreamsScreen.kt | 2 + .../player/PlayerSettingsStorage.ios.kt | 18 ++ .../streams/BingeGroupCacheStorage.ios.kt | 17 ++ 19 files changed, 694 insertions(+), 72 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index e4e5c4d6..94036653 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -44,6 +44,7 @@ import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage +import com.nuvio.app.features.streams.BingeGroupCacheStorage import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ResumePromptStorage @@ -87,6 +88,7 @@ class MainActivity : AppCompatActivity() { EpisodeReleaseNotificationsStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) + BingeGroupCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) CollectionMobileSettingsStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext) 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 d6e11982..743c27f8 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 @@ -51,6 +51,7 @@ actual object PlayerSettingsStorage { private const val introSubmitEnabledKey = "intro_submit_enabled" private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_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" @@ -101,6 +102,7 @@ actual object PlayerSettingsStorage { animeSkipClientIdKey, streamAutoPlayNextEpisodeEnabledKey, streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, nextEpisodeThresholdModeKey, nextEpisodeThresholdPercentKey, nextEpisodeThresholdMinutesBeforeEndKey, @@ -609,6 +611,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey), enabled) + ?.apply() + } + actual fun loadNextEpisodeThresholdMode(): String? = preferences?.getString(ProfileScopedKey.of(nextEpisodeThresholdModeKey), null) @@ -825,6 +844,7 @@ actual object PlayerSettingsStorage { loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } @@ -883,6 +903,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt new file mode 100644 index 00000000..253c3594 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.android.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.streams + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object BingeGroupCacheStorage { + private const val preferencesName = "nuvio_binge_group_cache" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun load(hashedKey: String): String? = + preferences?.getString(ProfileScopedKey.of(hashedKey), null) + + actual fun save(hashedKey: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(hashedKey), value) + ?.apply() + } + + actual fun remove(hashedKey: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(hashedKey)) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 1fa4e846..4a1efbc3 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -1199,7 +1199,6 @@ Fjern %1$s fra biblioteket ditt? Fjern fra bibliotek? Film - Serie Varsler når en ny episode for en lagret serie er ute. Forhåndsvisning av episodeutgivelsesvarsel. Kunne ikke sende testvarsel. diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 00af8afd..330c2cd6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1,4 +1,5 @@ + Źródła danych, podziękowania i licencje platformy Osoby wspierające i współtworzące projekt Wstecz Anuluj @@ -17,6 +18,8 @@ Wznów Ponów Zapisz + Zapisywanie… + Sprawdź Instalowanie Dodatki Aktywny @@ -110,29 +113,38 @@ Produkcja Stacja Kolekcja + Osoba + Reżyser Niestandardowy Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu. Wklej publiczny URL listy TMDB lub sam numer z URL. Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio. Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach. Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB. + Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii aktora. + Wprowadź ID osoby TMDB lub URL, aby zbudować wiersz z filmografii reżysera. Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra. Publiczna lista TMDB ID stacji ID kolekcji + ID osoby Nazwa firmy produkcyjnej, ID lub URL ID lub URL TMDB https://www.themoviedb.org/list/8504994 lub 8504994 213 dla Netflix, 49 dla HBO, 2739 dla Disney+ 10 dla kolekcji Star Wars Marvel Studios, 420 lub URL firmy + 31 dla Toma Hanksa lub URL osoby Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420. Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji. Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739. Przykład: https://www.themoviedb.org/list/8504994 lub 8504994. + Przykład: https://www.themoviedb.org/person/31-tom-hanks lub 31. Wyświetlany tytuł Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła. Filmy Marvela, Oryginały Netflix, Pixar + Filmy Toma Hanksa, Ulubieni aktorzy + Filmy Christophera Nolana, Ulubieni reżyserzy Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024 Wyniki wyszukiwania Kolekcja TMDB @@ -179,6 +191,27 @@ Szablony Szukaj Dodaj źródło + Dodaj listę Trakt + Edytuj listę Trakt + Listy Trakt + Lista Trakt + Szukaj tytułu, URL Trakt lub ID listy + Użyj publicznego URL listy Trakt, numerycznego ID listy lub wyszukaj po nazwie. + Weekendowe filmy, Laureaci nagród + Wyniki wyszukiwania + Popularne teraz + Popularne listy + Kierunek + Rosnąco + Malejąco + Kolejność listy + Ostatnio dodane + Tytuł + Data premiery + Czas trwania + Popularne + Procent + Głosy Akcja Przygodowy Animacja @@ -212,13 +245,29 @@ Disney+ Prime Video Hulu + Oryginalna Popularne Najwyżej oceniane Ostatnie + Najczęściej głosowane + Region dostępności + Kod kraju ISO 3166-1, w którym tytuł jest dostępny. Przykład: US, GB. + Popularne regiony dostępności + ID platform streamingowych + Użyj ID platform TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR. + 8|337|350 + Popularne platformy streamingowe + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu Lista TMDB Kolekcja filmów TMDB Produkcja Stacja + Osoba + Reżyser TMDB Discover Utwórz jedną, aby uporządkować katalogi. Brak kolekcji @@ -331,8 +380,10 @@ Wygląd Treści i odkrywanie Kontynuuj oglądanie + Debrid Ekran główny Integracje + Licencje i atrybucje Oceny MDBList Ekran metadanych Powiadomienia @@ -358,6 +409,31 @@ Przełącz na inny profil. Przełącz profil Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt. + Nie znaleziono ustawień. + Szukaj ustawień... + WYNIKI + LICENCJA APLIKACJI + DANE I USŁUGI + LICENCJA ODTWARZANIA + Nuvio Mobile + Kod źródłowy i warunki licencji są dostępne w repozytorium projektu. + Licencjonowany na podstawie GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio korzysta z API TMDB do metadanych filmów i seriali, grafik, zwiastunów, obsady, szczegółów produkcji, kolekcji i rekomendacji. Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB. + Niekomercyjne zbiory danych IMDb + Nuvio korzysta z niekomercyjnych zbiorów danych IMDb, w tym title.ratings.tsv.gz, do ocen i liczby głosów IMDb. Informacje dzięki uprzejmości IMDb (https://www.imdb.com). Wykorzystywane za zgodą. Dane IMDb służą do użytku osobistego i niekomercyjnego zgodnie z warunkami IMDb. + Trakt + Nuvio łączy się z Trakt w celu uwierzytelniania konta, historii oglądania, synchronizacji postępu, danych biblioteki, ocen, list i komentarzy. Nuvio nie jest powiązane z Trakt ani przez nie wspierane. + MDBList + Nuvio korzysta z MDBList do ocen i danych zewnętrznych dostawców ocen. Nuvio nie jest powiązane z MDBList ani przez nie wspierane. + IntroDB + Nuvio korzysta z API IntroDB do dostarczanych przez społeczność znaczników intro, podsumowań, napisów końcowych i podglądów używanych przez kontrolki pomijania. Nuvio nie jest powiązane z IntroDB ani przez nie wspierane. + MPVKit + Używany do odtwarzania w wersjach na iOS. + Kod źródłowy MPVKit jest licencjonowany na podstawie LGPL v3.0. Pakiety MPVKit, w tym biblioteki libmpv i FFmpeg, są również licencjonowane na podstawie LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Używany do odtwarzania w wersjach na Androida. + Licencjonowany na podstawie Apache License, wersja 2.0. Ładowanie list Trakt… Wybierz, gdzie zapisać ten tytuł w Trakt Wesprzyj @@ -416,6 +492,8 @@ Język aplikacji Wybierz język Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. + Liquid Glass + Użyj natywnego paska kart iPhone na iOS 26 i nowszych. Szybkie przełączanie profili z paska kart jest niedostępne, gdy ta opcja jest włączona. Dostosuj szerokość i zaokrąglenie rogów kart plakatów. WYŚWIETLANIE EKRAN GŁÓWNY @@ -442,9 +520,14 @@ %1$d z %2$d wybranych Pokaż Hero Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej. + Ukryj niewydane treści + Ukryj filmy i seriale, które nie zostały jeszcze wydane. + Ukryj podkreślenie katalogu + Usuń linię akcentu pod tytułami katalogów i kolekcji w całej aplikacji. %1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność. Widoczne + Ukryj wartość Odtwarzacz, napisy i automatyczne odtwarzanie Zaokrąglenie karty STYL KARTY PLAKATU @@ -469,8 +552,19 @@ Gęsty Duży Standardowy + Pokaż wartość Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza. Monit o wznowienie przy uruchomieniu + Rozmyj miniatury następnych odcinków w Kontynuuj oglądanie, aby uniknąć spoilerów. + Rozmyj nieobejrzane w Kontynuuj oglądanie + Uwzględnij nadchodzące odcinki w Kontynuuj oglądanie przed ich emisją. + Pokaż niewyemitowane następne odcinki + KOLEJNOŚĆ SORTOWANIA + Kolejność sortowania + Domyślna + Sortuj wszystkie elementy według czasu + Styl streamingowy + Wydane najpierw, nadchodzące na końcu STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO @@ -483,6 +577,8 @@ Pozioma karta z informacjami Gdy włączone, Następny zawsze kontynuuje od najdalej obejrzanego odcinka. Gdy wyłączone, kontynuuje od ostatnio obejrzanego. Przydatne przy ponownym oglądaniu wcześniejszych odcinków. Następny od najdalszego odcinka + Preferuj miniatury odcinków, gdy są dostępne. + Preferuj miniatury odcinków w Kontynuuj oglądanie EKRAN GŁÓWNY ŹRÓDŁA Instaluj, usuwaj, odświeżaj i sortuj źródła treści. @@ -493,6 +589,34 @@ INTEGRACJE Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. + Eksperymentalne źródła z kont chmurowych + Debrid + Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. + Włącz źródła + Pokaż odtwarzalne wyniki z połączonych kont. + Najpierw dodaj klucz API. + Konto + Połącz swoje konto Torbox. + Klucz API Torbox + Wprowadź swój klucz API Torbox. + Wprowadź klucz API Torbox + Nie ustawiono + Natychmiastowe odtwarzanie + Przygotuj linki + Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. + Źródła do przygotowania + Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem. + 1 źródło + %1$d źródeł + Formatowanie + Szablon nazwy + Kontroluje sposób wyświetlania nazw źródeł. + Szablon opisu + Kontroluje metadane wyświetlane pod każdym źródłem. + Resetuj formatowanie + Przywróć domyślne formatowanie źródeł. + Klucz API zweryfikowany. + Nie udało się zweryfikować tego klucza API. Dodaj klucz API MDBList poniżej przed włączeniem ocen. Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. Klucz API @@ -522,6 +646,8 @@ Karty ze szczegółami na pierwszym planie Odcinki Sezony i lista odcinków dla seriali. + Rozmyj nieobejrzane odcinki + Rozmyj miniatury odcinków do momentu obejrzenia, aby uniknąć spoilerów. Grupa %1$d Podobne Wiersz rekomendacji. @@ -588,6 +714,10 @@ Anime Skip ID klienta AnimeSkip Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com. + Włącz przesyłanie intro + Pokaż przycisk do przesyłania znaczników intro/outro do bazy społeczności. + Klucz API IntroDB + Wprowadź klucz API IntroDB, aby przesyłać znaczniki czasowe. Wymagany do przesyłania. Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta). Automatyczne odtwarzanie następnego odcinka Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu. @@ -603,6 +733,11 @@ %1$d godzin Włącz libass Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera. + Zewnętrzny odtwarzacz + Aplikacja zewnętrznego odtwarzacza + Otwórz nowe odtwarzanie w domyślnej aplikacji wideo Androida lub selektorze systemowym. + Otwórz nowe odtwarzanie w wybranym zainstalowanym odtwarzaczu. + Brak zainstalowanych obsługiwanych zewnętrznych odtwarzaczy Prędkość przy przytrzymaniu Przytrzymaj, aby przyspieszyć Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania. @@ -621,6 +756,8 @@ Brak Preferuj grupę binge Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący. + Ponownie użyj grupy binge + Zapamiętaj i ponownie użyj ostatniej grupy binge między sesjami (Kontynuuj oglądanie, Szczegóły itp.). Preferowany język audio Preferowany język napisów Szablony @@ -744,6 +881,28 @@ Otwórz logowanie Trakt Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt. Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt. + Źródło biblioteki + Wybierz, której biblioteki używać do zapisywania i przeglądania kolekcji + Źródło biblioteki + Wybierz, gdzie zapisywać i zarządzać elementami biblioteki + Trakt + Biblioteka Nuvio + Wybrano bibliotekę Trakt + Wybrano bibliotekę Nuvio + Postęp oglądania + Wybierz, które źródło postępu obsługuje wznawianie i Kontynuuj oglądanie + Postęp oglądania + Wybierz, czy wznawianie i Kontynuuj oglądanie powinno korzystać z Trakt czy Nuvio Sync, podczas gdy scrobblowanie Trakt pozostaje aktywne. + Trakt + Nuvio Sync + Źródło postępu ustawione na Trakt + Źródło postępu ustawione na Nuvio Sync + Okno Kontynuuj oglądanie + Historia Trakt uwzględniana w Kontynuuj oglądanie + Okno Kontynuuj oglądanie + Wybierz, ile aktywności Trakt ma się pojawiać w Kontynuuj oglądanie. + Cała historia + %1$d dni Ocena widzów IMDb Letterboxd @@ -934,9 +1093,14 @@ Zablokowane. Spróbuj ponownie za %1$ds Opcje awatarów pojawią się tutaj po załadowaniu katalogu. Awatar: %1$s + Wprowadź prawidłowy URL obrazu http:// lub https://. Wybierz awatar Wybierz awatar poniżej. Utwórz profil + Wybrano niestandardowy URL awatara. + Niestandardowy URL awatara + Wklej link do obrazu lub zostaw puste, aby użyć wbudowanego katalogu awatarów. + https://example.com/avatar.png Wszystkie dane profilu „%1$s" zostaną trwale usunięte. Usuń profil Dodaj profil @@ -968,6 +1132,8 @@ Sprawdzanie kolejnych dodatków… Kopiuj link strumienia Pobierz plik + Otwórz w zewnętrznym odtwarzaczu + Otwórz w wewnętrznym odtwarzaczu Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi. Nie można załadować strumieni Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu. @@ -987,6 +1153,13 @@ Wznów od %1$d% Wznów od %1$s ROZMIAR %1$s + Ten typ strumienia nie jest obsługiwany + Dodaj klucz API Debrid w Ustawieniach. + Ten wynik Debrid wygasł. Odświeżanie strumieni. + Nie udało się rozwiązać tego strumienia Debrid. + Nie udało się otworzyć zewnętrznego odtwarzacza + Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach + Brak dostępnego zewnętrznego odtwarzacza Zamknij zwiastun Nie można odtworzyć zwiastuna Nie udało się załadować list Trakt @@ -1032,6 +1205,7 @@ Pobieranie nie powiodło się Wstrzymano %1$s Usuń + Usunąć %1$s z %2$s? Usunąć %1$s z biblioteki? Usunąć z biblioteki? Film @@ -1075,6 +1249,7 @@ Folder %1$d w „%2$s" ma puste id. Folder „%1$s" w „%2$s" ma pusty tytuł. Źródło %1$d w folderze „%2$s" ma puste pola. + Źródło %1$d w folderze '%2$s' nie ma ID listy Trakt. Nieprawidłowy JSON: %1$s Nie znaleziono dodatku: %1$s Styczeń @@ -1148,6 +1323,14 @@ Nowy odcinek jest już dostępny %1$s jest już dostępny Premiery odcinków + Alkohol/Narkotyki + Przerażające + Nagość + Wulgaryzmy + Łagodne + Umiarkowane + Intensywne + Przemoc Twórca Reżyser Scenarzysta diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ddbb9b65..601a432c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -756,6 +756,8 @@ None Prefer Binge Group (Next Episode) Try the same source profile first (same addon/quality group) before normal auto-play rules. + Reuse Binge Group + Remember and reuse the last binge group across sessions (Continue Watching, Details, etc.). Preferred Audio Language Preferred Language Presets diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..e486476d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1527,6 +1527,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, @@ -1636,6 +1637,7 @@ private fun MainAppContent( StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, season = launch.seasonNumber, episode = launch.episodeNumber, manualSelection = launch.manualSelection, 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 476b0a77..a18b195d 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 @@ -56,8 +56,10 @@ 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.BingeGroupCacheRepository import com.nuvio.app.features.streams.StreamAutoPlayMode import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState @@ -73,6 +75,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import kotlin.math.abs @@ -86,6 +89,8 @@ private const val PlayerLockedOverlayDurationMs = 2_000L private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerRightGestureBoundary = 0.6f private const val PlayerVerticalGestureSensitivity = 1f +/** Hard ceiling for next-episode stream search to prevent hanging forever. */ +private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L private val PlayerSliderOverlayGap = 12.dp private val PlayerTimeRowHeight = 36.dp private val PlayerActionRowHeight = 50.dp @@ -323,6 +328,15 @@ fun PlayerScreen( } } + // Persist binge group per content so subsequent episode plays + // (from CW, Details, or next-episode) can reuse the same source group. + LaunchedEffect(currentStreamBingeGroup, parentMetaId) { + val bg = currentStreamBingeGroup + if (bg != null && parentMetaId.isNotBlank()) { + BingeGroupCacheRepository.save(parentMetaId, bg) + } + } + ManagePlayerPictureInPicture( isPlaying = playbackSnapshot.isPlaying, playerSize = layoutSize, @@ -1101,6 +1115,12 @@ fun PlayerScreen( settings.streamAutoPlayPreferBingeGroup ) + // bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle) + val bingeGroupOnlyManualMode = + shouldAutoSelectInManualMode && + !settings.streamAutoPlayNextEpisodeEnabled && + settings.streamAutoPlayPreferBingeGroup + // Determine auto-play mode for next episode val effectiveMode = if (shouldAutoSelectInManualMode) { StreamAutoPlayMode.FIRST_STREAM @@ -1108,7 +1128,7 @@ fun PlayerScreen( settings.streamAutoPlayMode } val effectiveSource = if (shouldAutoSelectInManualMode) { - com.nuvio.app.features.streams.StreamAutoPlaySource.ALL_SOURCES + StreamAutoPlaySource.ALL_SOURCES } else { settings.streamAutoPlaySource } @@ -1128,6 +1148,13 @@ fun PlayerScreen( settings.streamAutoPlayRegex } + // Determine preferred binge group from current stream (not cache) + val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { + currentStreamBingeGroup + } else { + null + } + nextEpisodeAutoPlayJob = scope.launch { PlayerStreamsRepository.loadEpisodeStreams( type = type, @@ -1140,59 +1167,171 @@ fun PlayerScreen( .map { it.displayTitle } .toSet() - val timeoutMs = settings.streamAutoPlayTimeoutSeconds * 1000L - val startTime = WatchProgressClock.nowEpochMs() + val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds + val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE + var autoSelectTriggered = false + var timeoutElapsed = false + var selectedStream: StreamItem? = null - // Collect streams as they arrive - PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> - if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest + // Full select: tries binge group first, then falls back to mode-based selection + fun trySelectStream(streams: List): StreamItem? { + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + bingeGroupOnly = bingeGroupOnlyManualMode, + ) + } - val allStreams = state.groups.flatMap { it.streams } - val elapsed = WatchProgressClock.nowEpochMs() - startTime + // Binge group only early match: returns null if no binge group match + fun tryBingeGroupOnly(streams: List): StreamItem? { + if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + ) + } - val selected = if (allStreams.isNotEmpty()) { - StreamAutoPlaySelector.selectAutoPlayStream( - streams = allStreams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { - currentStreamBingeGroup - } else { - null - }, - preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - ) - } else null + val innerJob = launch { + // Collect streams as they arrive + PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> + if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest - 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) + val allStreams = state.groups.flatMap { it.streams } + + if (autoSelectTriggered) { + // Already resolved + } else if (timeoutElapsed) { + // Timeout elapsed: full select (binge group + fallback to mode) + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + autoSelectTriggered = true + selectedStream = candidate + } + } + } else { + // Before timeout: eagerly check binge group only + if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + autoSelectTriggered = true + selectedStream = earlyMatch + } } - 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 + + // If all addons finished loading and no match yet, do a final full select + if (!autoSelectTriggered && !state.isAnyLoading) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + autoSelectTriggered = true + selectedStream = candidate + } + } + if (!autoSelectTriggered) { + autoSelectTriggered = true + } + return@collectLatest + } + + if (autoSelectTriggered) return@collectLatest } } + + // Timeout logic + val timeoutMs = timeoutSeconds * 1_000L + val isBoundedTimeout = timeoutSeconds in 1..30 + + if (isBoundedTimeout) { + // Bounded timeout (1-30s): wait, then trigger full select + delay(timeoutMs) + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + autoSelectTriggered = true + selectedStream = candidate + } + } + } + if (selectedStream != null) { + innerJob.cancel() + } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { + // Streams arrived but no match after full select — don't wait further + innerJob.cancel() + autoSelectTriggered = true + } else { + // No addon responded yet — wait with hard ceiling + val completed = withTimeoutOrNull(timeoutMs) { innerJob.join() } + if (completed == null) { + innerJob.cancel() + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + autoSelectTriggered = true + } + } + } + } else { + // Instant (0) or unlimited: timeoutElapsed immediately so each + // addon response triggers a full select attempt in the collect. + timeoutElapsed = true + val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS + val completed = withTimeoutOrNull(hardTimeout) { innerJob.join() } + if (completed == null) { + innerJob.cancel() + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + autoSelectTriggered = true + } + } + } + + // Handle result + nextEpisodeAutoPlaySearching = false + if (selectedStream != null) { + nextEpisodeAutoPlaySourceName = selectedStream!!.addonName + // Countdown before playing + for (i in 3 downTo 1) { + nextEpisodeAutoPlayCountdown = i + delay(1000) + } + switchToEpisodeStream(selectedStream!!, nextVideo) + showNextEpisodeCard = false + nextEpisodeAutoPlayCountdown = null + nextEpisodeAutoPlaySourceName = null + } else { + // No stream found — open the episode streams panel for manual selection + episodeStreamsPanelState = EpisodeStreamsPanelState( + showStreams = true, + selectedEpisode = nextVideo, + ) + showEpisodesPanel = true + showNextEpisodeCard = false + } } } @@ -1890,7 +2029,7 @@ fun PlayerScreen( // Skip intro/recap/outro button if (!playerControlsLocked) { SkipIntroButton( - interval = activeSkipInterval, + interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, dismissed = skipIntervalDismissed, controlsVisible = controlsVisible, onSkip = { 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 1c5cd8c7..3ccbfea8 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 @@ -7,6 +7,29 @@ import com.nuvio.app.features.streams.StreamAutoPlaySource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.abs + +val STREAM_AUTO_PLAY_TIMEOUT_VALUES: List = listOf( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, Int.MAX_VALUE +) + +/** + * Snaps [value] to the nearest allowed timeout value in [STREAM_AUTO_PLAY_TIMEOUT_VALUES]. + * Ties break to the lower value. Negative values snap to 0. + */ +fun snapToAllowedTimeout(value: Int): Int { + if (value <= 0) return 0 + var bestValue = STREAM_AUTO_PLAY_TIMEOUT_VALUES[0] + var bestDistance = Long.MAX_VALUE + for (allowed in STREAM_AUTO_PLAY_TIMEOUT_VALUES) { + val distance = abs(value.toLong() - allowed.toLong()) + if (distance < bestDistance || (distance == bestDistance && allowed < bestValue)) { + bestDistance = distance + bestValue = allowed + } + } + return bestValue +} data class PlayerSettingsUiState( val showLoadingOverlay: Boolean = true, @@ -38,6 +61,7 @@ data class PlayerSettingsUiState( val introSubmitEnabled: Boolean = false, val streamAutoPlayNextEpisodeEnabled: Boolean = false, val streamAutoPlayPreferBingeGroup: Boolean = true, + val streamAutoPlayReuseBingeGroup: Boolean = true, val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE, val nextEpisodeThresholdPercent: Float = 99f, val nextEpisodeThresholdMinutesBeforeEnd: Float = 2f, @@ -93,6 +117,7 @@ object PlayerSettingsRepository { private var introSubmitEnabled = false private var streamAutoPlayNextEpisodeEnabled = false private var streamAutoPlayPreferBingeGroup = true + private var streamAutoPlayReuseBingeGroup = true private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE private var nextEpisodeThresholdPercent = 99f private var nextEpisodeThresholdMinutesBeforeEnd = 2f @@ -153,6 +178,7 @@ object PlayerSettingsRepository { introSubmitEnabled = false streamAutoPlayNextEpisodeEnabled = false streamAutoPlayPreferBingeGroup = true + streamAutoPlayReuseBingeGroup = true nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE nextEpisodeThresholdPercent = 99f nextEpisodeThresholdMinutesBeforeEnd = 2f @@ -232,6 +258,14 @@ object PlayerSettingsRepository { } streamAutoPlayRegex = PlayerSettingsStorage.loadStreamAutoPlayRegex() ?: "" streamAutoPlayTimeoutSeconds = PlayerSettingsStorage.loadStreamAutoPlayTimeoutSeconds() ?: 3 + // Legacy migration: 11 was the old sentinel for "unlimited" + if (streamAutoPlayTimeoutSeconds == 11) { + streamAutoPlayTimeoutSeconds = Int.MAX_VALUE + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(streamAutoPlayTimeoutSeconds) + } else if (streamAutoPlayTimeoutSeconds !in STREAM_AUTO_PLAY_TIMEOUT_VALUES) { + streamAutoPlayTimeoutSeconds = snapToAllowedTimeout(streamAutoPlayTimeoutSeconds) + PlayerSettingsStorage.saveStreamAutoPlayTimeoutSeconds(streamAutoPlayTimeoutSeconds) + } skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: "" @@ -239,6 +273,7 @@ object PlayerSettingsRepository { introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true + streamAutoPlayReuseBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayReuseBingeGroup() ?: true nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode() ?.let { runCatching { NextEpisodeThresholdMode.valueOf(it) }.getOrNull() } ?: NextEpisodeThresholdMode.PERCENTAGE @@ -524,6 +559,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveStreamAutoPlayPreferBingeGroup(enabled) } + fun setStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + ensureLoaded() + if (streamAutoPlayReuseBingeGroup == enabled) return + streamAutoPlayReuseBingeGroup = enabled + publish() + PlayerSettingsStorage.saveStreamAutoPlayReuseBingeGroup(enabled) + } + fun setNextEpisodeThresholdMode(mode: NextEpisodeThresholdMode) { ensureLoaded() if (nextEpisodeThresholdMode == mode) return @@ -753,6 +796,7 @@ object PlayerSettingsRepository { introSubmitEnabled = introSubmitEnabled, streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled, streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup, + streamAutoPlayReuseBingeGroup = streamAutoPlayReuseBingeGroup, 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 d36cd301..2b07020e 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 @@ -68,6 +68,8 @@ internal expect object PlayerSettingsStorage { fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) fun loadStreamAutoPlayPreferBingeGroup(): Boolean? fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) + fun loadStreamAutoPlayReuseBingeGroup(): Boolean? + fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) fun loadNextEpisodeThresholdMode(): String? fun saveNextEpisodeThresholdMode(mode: String) fun loadNextEpisodeThresholdPercent(): Float? 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 83daa6df..98b1a83b 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 @@ -59,6 +59,7 @@ import com.nuvio.app.features.player.IosHardwareDecoderMode import com.nuvio.app.features.player.IosTargetPrimaries import com.nuvio.app.features.player.IosTargetTransfer import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.player.STREAM_AUTO_PLAY_TIMEOUT_VALUES import com.nuvio.app.features.player.SubtitleLanguageOption import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.languageLabelForCode @@ -365,7 +366,7 @@ private fun PlaybackSettingsSection( val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds val timeoutLabel = when (timeoutSec) { 0 -> stringResource(Res.string.settings_playback_timeout_instant) - 11 -> stringResource(Res.string.settings_playback_timeout_unlimited) + Int.MAX_VALUE -> stringResource(Res.string.settings_playback_timeout_unlimited) else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) } Column( @@ -391,8 +392,11 @@ private fun PlaybackSettingsSection( } ValueBox(text = timeoutLabel, modifier = Modifier.wrapContentWidth()) } - var sliderValue by remember(timeoutSec) { mutableFloatStateOf(timeoutSec.toFloat()) } - var lastHapticStep by remember(timeoutSec) { mutableStateOf(timeoutSec.toFloat()) } + val timeoutIndex = STREAM_AUTO_PLAY_TIMEOUT_VALUES.indexOf(timeoutSec) + .coerceAtLeast(0) + val maxIndex = (STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1).toFloat() + var sliderValue by remember(timeoutIndex) { mutableFloatStateOf(timeoutIndex.toFloat()) } + var lastHapticStep by remember(timeoutIndex) { mutableStateOf(timeoutIndex.toFloat()) } Slider( value = sliderValue, onValueChange = { @@ -405,10 +409,11 @@ private fun PlaybackSettingsSection( } }, onValueChangeFinished = { - PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(sliderValue.toInt()) + val index = sliderValue.toInt().coerceIn(0, STREAM_AUTO_PLAY_TIMEOUT_VALUES.size - 1) + PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(STREAM_AUTO_PLAY_TIMEOUT_VALUES[index]) }, - valueRange = 0f..11f, - steps = calculateSteps(0f, 11f, 1f), + valueRange = 0f..maxIndex, + steps = calculateSteps(0f, maxIndex, 1f), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, @@ -658,6 +663,16 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayPreferBingeGroup, ) + if (autoPlayPlayerSettings.streamAutoPlayPreferBingeGroup) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_reuse_binge_group), + description = stringResource(Res.string.settings_playback_reuse_binge_group_description), + checked = autoPlayPlayerSettings.streamAutoPlayReuseBingeGroup, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayReuseBingeGroup, + ) + } SettingsGroupDivider(isTablet = isTablet) var showThresholdModeDialog by remember { mutableStateOf(false) } SettingsNavigationRow( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt new file mode 100644 index 00000000..5ca8968c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheRepository.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.streams + +object BingeGroupCacheRepository { + + fun save(contentId: String, bingeGroup: String) { + BingeGroupCacheStorage.save(hashedKey(contentId), bingeGroup) + } + + fun get(contentId: String): String? { + return BingeGroupCacheStorage.load(hashedKey(contentId)) + } + + fun remove(contentId: String) { + BingeGroupCacheStorage.remove(hashedKey(contentId)) + } + + private fun hashedKey(contentId: String): String { + val hash = contentId.fold(0L) { acc, c -> acc * 31 + c.code }.toULong() + return "binge_group_$hash" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt new file mode 100644 index 00000000..eda9a4ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.streams + +internal expect object BingeGroupCacheStorage { + fun load(hashedKey: String): String? + fun save(hashedKey: String, value: String) + fun remove(hashedKey: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt index 445af267..0ad4aa10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlayPolicy.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.player.PlayerSettingsUiState object StreamAutoPlayPolicy { fun isEffectivelyEnabled(settings: PlayerSettingsUiState): Boolean { if (settings.streamReuseLastLinkEnabled) return true + if (settings.streamAutoPlayReuseBingeGroup && settings.streamAutoPlayPreferBingeGroup) return true return when (settings.streamAutoPlayMode) { StreamAutoPlayMode.MANUAL -> false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 5917325a..a5e97de5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -40,6 +40,7 @@ object StreamAutoPlaySelector { selectedPlugins: Set, preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, + bingeGroupOnly: Boolean = false, ): StreamItem? { if (streams.isEmpty()) return null @@ -57,7 +58,7 @@ object StreamAutoPlaySelector { } } if (candidateStreams.isEmpty()) return null - if (mode == StreamAutoPlayMode.MANUAL) return null + if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) return null val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { @@ -65,6 +66,12 @@ object StreamAutoPlaySelector { stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() } if (bingeGroupMatch != null) return bingeGroupMatch + // When bingeGroupOnly = true, do NOT fall through to mode-based selection + if (bingeGroupOnly) return null + } else if (bingeGroupOnly) { + // bingeGroupOnly requested but no preferredBingeGroup or preferBingeGroupInSelection is false + // Fall through to mode-based selection (bingeGroupOnly has no effect without a binge group to match) + if (mode == StreamAutoPlayMode.MANUAL) return null } return when (mode) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 2fc87a24..5441ce47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -48,10 +48,11 @@ object StreamsRepository { ): String = "$type::$videoId::$season::$episode::$manualSelection" - fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun load(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = season, episode = episode, manualSelection = manualSelection, @@ -59,10 +60,11 @@ object StreamsRepository { ) } - fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { + fun reload(type: String, videoId: String, parentMetaId: String? = null, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = season, episode = episode, manualSelection = manualSelection, @@ -70,7 +72,7 @@ object StreamsRepository { ) } - private fun load(type: String, videoId: String, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { + private fun load(type: String, videoId: String, parentMetaId: String?, season: Int?, episode: Int?, manualSelection: Boolean, forceRefresh: Boolean) { val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.uiState.value @@ -105,7 +107,21 @@ object StreamsRepository { val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && !StreamAutoPlayPolicy.isRegexSelectionConfigured(playerSettings.streamAutoPlayRegex)) - val isDirectAutoPlayFlow = isAutoPlayEnabled + + // Look up persisted binge group when both settings are enabled + val persistedBingeGroup = if ( + playerSettings.streamAutoPlayPreferBingeGroup && + playerSettings.streamAutoPlayReuseBingeGroup + ) { + parentMetaId?.let { BingeGroupCacheRepository.get(it) } + } else null + + // Enable direct auto-play flow if normal auto-play is enabled, + // OR if we have a persisted binge group in MANUAL mode + val bingeGroupDirectFlow = !manualSelection && + persistedBingeGroup != null && + autoPlayMode == StreamAutoPlayMode.MANUAL + val isDirectAutoPlayFlow = isAutoPlayEnabled || bingeGroupDirectFlow if (isDirectAutoPlayFlow) { _uiState.value = StreamsUiState( @@ -237,16 +253,59 @@ object StreamsRepository { } } - val timeoutJob = if (isAutoPlayEnabled) { - val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L - if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) { + val timeoutJob = if (isDirectAutoPlayFlow) { + val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds + val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE + // Timeout semantics: + // - 0 (instant): timeoutElapsed immediately, full select on each response + // - 1-30 (bounded): wait the configured delay, then full select + // - unlimited (Int.MAX_VALUE): timeoutElapsed immediately, full select on each response, + // with 60s hard fallback to stream picker + if (timeoutSeconds <= 0 || isUnlimitedTimeout) { + timeoutElapsed = true + // For unlimited: launch a hard 60s fallback to dismiss overlay + if (isUnlimitedTimeout) { + launch { + delay(60_000L) + if (!autoSelectTriggered) { + autoSelectTriggered = true + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, + ) + _uiState.update { it.copy(autoPlayStream = selected) } + } + if (_uiState.value.autoPlayStream == null) { + _uiState.update { + it.copy( + isDirectAutoPlayFlow = false, + showDirectAutoPlayOverlay = false, + ) + } + } + } + } + } else { + null + } + } else { + // Bounded timeout (1-30s) launch { - delay(timeoutMs) + delay(timeoutSeconds * 1_000L) timeoutElapsed = true if (!autoSelectTriggered) { val allStreams = _uiState.value.groups.flatMap { it.streams } if (allStreams.isNotEmpty()) { - autoSelectTriggered = true val selected = StreamAutoPlaySelector.selectAutoPlayStream( streams = allStreams, mode = autoPlayMode, @@ -255,9 +314,14 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, ) - _uiState.update { it.copy(autoPlayStream = selected) } - if (selected == null) { + if (selected != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = selected) } + } else { _uiState.update { it.copy( isDirectAutoPlayFlow = false, @@ -268,11 +332,6 @@ object StreamsRepository { } } } - } else if (timeoutMs <= 0L) { - timeoutElapsed = true - null - } else { - null } } else { null @@ -479,9 +538,54 @@ object StreamsRepository { } } } + + // Early match / timeout-elapsed auto-select on each addon response + if (isDirectAutoPlayFlow && !autoSelectTriggered) { + val allStreams = _uiState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + if (timeoutElapsed) { + // After timeout: full fallback (bingeGroupOnly = false) + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, + ) + if (selected != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = selected) } + } + } else if (persistedBingeGroup != null) { + // Before timeout: try binge-group-only early match + val earlyMatch = StreamAutoPlaySelector.selectAutoPlayStream( + streams = allStreams, + mode = autoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + ) + if (earlyMatch != null) { + autoSelectTriggered = true + _uiState.update { it.copy(autoPlayStream = earlyMatch) } + } + } + } + } } - if (isAutoPlayEnabled && !autoSelectTriggered) { + // All addons finished — run final auto-select if not yet triggered + if (isDirectAutoPlayFlow && !autoSelectTriggered) { autoSelectTriggered = true val allStreams = _uiState.value.groups.flatMap { it.streams } val selected = StreamAutoPlaySelector.selectAutoPlayStream( @@ -492,6 +596,9 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + preferredBingeGroup = persistedBingeGroup, + preferBingeGroupInSelection = persistedBingeGroup != null, + bingeGroupOnly = false, ) _uiState.update { it.copy(autoPlayStream = selected) } } @@ -512,6 +619,7 @@ object StreamsRepository { } fun consumeAutoPlay() { + activeRequestKey = null _uiState.update { it.copy( autoPlayStream = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index e7078fd9..ee5b52e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -176,6 +176,7 @@ fun StreamsScreen( StreamsRepository.load( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, @@ -277,6 +278,7 @@ fun StreamsScreen( StreamsRepository.reload( type = type, videoId = videoId, + parentMetaId = parentMetaId, season = seasonNumber, episode = episodeNumber, manualSelection = manualSelection, 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 9e539f5d..48649658 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 @@ -49,6 +49,7 @@ actual object PlayerSettingsStorage { private const val introSubmitEnabledKey = "intro_submit_enabled" private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val streamAutoPlayReuseBingeGroupKey = "stream_auto_play_reuse_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" @@ -99,6 +100,7 @@ actual object PlayerSettingsStorage { animeSkipClientIdKey, streamAutoPlayNextEpisodeEnabledKey, streamAutoPlayPreferBingeGroupKey, + streamAutoPlayReuseBingeGroupKey, nextEpisodeThresholdModeKey, nextEpisodeThresholdPercentKey, nextEpisodeThresholdMinutesBeforeEndKey, @@ -554,6 +556,20 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayPreferBingeGroupKey)) } + actual fun loadStreamAutoPlayReuseBingeGroup(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveStreamAutoPlayReuseBingeGroup(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(streamAutoPlayReuseBingeGroupKey)) + } + actual fun loadNextEpisodeThresholdMode(): String? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(nextEpisodeThresholdModeKey) @@ -725,6 +741,7 @@ actual object PlayerSettingsStorage { loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayReuseBingeGroup()?.let { put(streamAutoPlayReuseBingeGroupKey, encodeSyncBoolean(it)) } loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } @@ -782,6 +799,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncBoolean(streamAutoPlayReuseBingeGroupKey)?.let(::saveStreamAutoPlayReuseBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt new file mode 100644 index 00000000..45e807a4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/BingeGroupCacheStorage.ios.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object BingeGroupCacheStorage { + actual fun load(hashedKey: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(hashedKey)) + + actual fun save(hashedKey: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(hashedKey)) + } + + actual fun remove(hashedKey: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(hashedKey)) + } +} From de68ce2c309c2c5f43cd2b2e9bd491a85eff50c7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 16:45:35 +0530 Subject: [PATCH 19/19] ref(sync): pull method to support optional parameters for last watched and limit --- .../watching/sync/ProgressSyncAdapter.kt | 6 +- .../sync/SupabaseProgressSyncAdapter.kt | 16 ++++- .../watchprogress/WatchProgressRepository.kt | 70 +++++++++++-------- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt index d8449cce..3109c4fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -14,7 +14,11 @@ data class ProgressSyncRecord( ) interface ProgressSyncAdapter { - suspend fun pull(profileId: Int): List + suspend fun pull( + profileId: Int, + sinceLastWatched: Long? = null, + limit: Int? = null, + ): List suspend fun push( profileId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 083a3b93..6743de34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -17,8 +17,20 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { encodeDefaults = true } - override suspend fun pull(profileId: Int): List { - val params = buildJsonObject { put("p_profile_id", profileId) } + override suspend fun pull( + profileId: Int, + sinceLastWatched: Long?, + limit: Int?, + ): List { + val params = buildJsonObject { + put("p_profile_id", profileId) + if (sinceLastWatched != null) { + put("p_since_last_watched", sinceLastWatched) + } + if (limit != null) { + put("p_limit", limit) + } + } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val serverEntries = result.decodeList() return serverEntries.map { entry -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index d452d3a8..8f4569b3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -12,6 +12,7 @@ import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions +import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CancellationException @@ -180,38 +181,23 @@ object WatchProgressRepository { } runCatching { - val serverEntries = syncAdapter.pull(profileId = profileId) - + val sinceLastWatched = entriesByVideoId.values + .maxOfOrNull { entry -> entry.lastUpdatedEpochMs } + ?.takeIf { hasCompletedInitialNuvioSyncPull } + val serverEntries = syncAdapter.pull( + profileId = profileId, + sinceLastWatched = sinceLastWatched, + ) + val isIncrementalPull = sinceLastWatched != null val oldLocal = entriesByVideoId.toMap() - val newMap = mutableMapOf() + val newMap = if (isIncrementalPull) { + entriesByVideoId.toMutableMap() + } else { + mutableMapOf() + } serverEntries.forEach { entry -> - val videoId = entry.videoId - val cached = oldLocal[videoId] - newMap[videoId] = WatchProgressEntry( - contentType = entry.contentType, - parentMetaId = entry.contentId, - parentMetaType = cached?.parentMetaType ?: entry.contentType, - videoId = videoId, - title = cached?.title?.takeIf { it.isNotBlank() } ?: entry.contentId, - logo = cached?.logo, - poster = cached?.poster, - background = cached?.background, - seasonNumber = entry.season, - episodeNumber = entry.episode, - episodeTitle = cached?.episodeTitle, - episodeThumbnail = cached?.episodeThumbnail, - lastPositionMs = entry.position, - durationMs = entry.duration, - lastUpdatedEpochMs = entry.lastWatched, - providerName = cached?.providerName, - providerAddonId = cached?.providerAddonId, - lastStreamTitle = cached?.lastStreamTitle, - lastStreamSubtitle = cached?.lastStreamSubtitle, - pauseDescription = cached?.pauseDescription, - lastSourceUrl = cached?.lastSourceUrl, - isCompleted = isWatchProgressComplete(entry.position, entry.duration, false), - ) + newMap[entry.videoId] = entry.toWatchProgressEntry(cached = oldLocal[entry.videoId]) } entriesByVideoId = newMap @@ -232,6 +218,32 @@ object WatchProgressRepository { } } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = + WatchProgressEntry( + contentType = contentType, + parentMetaId = contentId, + parentMetaType = cached?.parentMetaType ?: contentType, + videoId = videoId, + title = cached?.title?.takeIf { it.isNotBlank() } ?: contentId, + logo = cached?.logo, + poster = cached?.poster, + background = cached?.background, + seasonNumber = season, + episodeNumber = episode, + episodeTitle = cached?.episodeTitle, + episodeThumbnail = cached?.episodeThumbnail, + lastPositionMs = position, + durationMs = duration, + lastUpdatedEpochMs = lastWatched, + providerName = cached?.providerName, + providerAddonId = cached?.providerAddonId, + lastStreamTitle = cached?.lastStreamTitle, + lastStreamSubtitle = cached?.lastStreamSubtitle, + pauseDescription = cached?.pauseDescription, + lastSourceUrl = cached?.lastSourceUrl, + isCompleted = isWatchProgressComplete(position, duration, false), + ) + private fun resolveRemoteMetadata() { val needsResolution = entriesByVideoId.values .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() }