From a36e927c0b9a1f062d8ff3cc09415c4dc6c6d915 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Tue, 5 May 2026 13:51:16 +0530
Subject: [PATCH 01/18] 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 2c747aca..0a0e7524 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=50
-MARKETING_VERSION=0.1.13
+CURRENT_PROJECT_VERSION=53
+MARKETING_VERSION=0.1.14
From 06553b9b26fd9c46569d11dfca37bd1550d8e631 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Tue, 5 May 2026 20:43:40 +0530
Subject: [PATCH 02/18] ref: add submit intro button to top player controls
---
.../app/features/player/PlayerControls.kt | 23 +++++++++++--------
1 file changed, 14 insertions(+), 9 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 48ffd528..13f975ed 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
@@ -131,6 +131,7 @@ internal fun PlayerControlsShell(
episodeTitle = episodeTitle,
metrics = metrics,
isLocked = isLocked,
+ onSubmitIntroClick = onSubmitIntroClick,
onLockToggle = onLockToggle,
onBack = onBack,
modifier = Modifier
@@ -168,7 +169,6 @@ internal fun PlayerControlsShell(
onAudioClick = onAudioClick,
onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick,
- onSubmitIntroClick = onSubmitIntroClick,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
@@ -189,6 +189,7 @@ private fun PlayerHeader(
episodeTitle: String?,
metrics: PlayerLayoutMetrics,
isLocked: Boolean,
+ onSubmitIntroClick: (() -> Unit)?,
onLockToggle: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@@ -264,6 +265,15 @@ private fun PlayerHeader(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
+ if (onSubmitIntroClick != null) {
+ PlayerHeaderIconButton(
+ icon = Icons.Rounded.Flag,
+ contentDescription = "Submit Intro",
+ buttonSize = metrics.headerIconSize + 16.dp,
+ iconSize = metrics.headerIconSize,
+ onClick = onSubmitIntroClick,
+ )
+ }
PlayerHeaderIconButton(
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
contentDescription = if (isLocked) {
@@ -424,7 +434,6 @@ private fun ProgressControls(
onAudioClick: () -> Unit,
onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null,
- onSubmitIntroClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
@@ -506,13 +515,6 @@ private fun ProgressControls(
onClick = onEpisodesClick,
)
}
- if (onSubmitIntroClick != null) {
- PlayerActionPillButton(
- label = "Submit Intro",
- icon = Icons.Rounded.Flag,
- onClick = onSubmitIntroClick,
- )
- }
}
}
}
@@ -676,6 +678,9 @@ private fun PlayerActionPillButton(
text = label,
style = MaterialTheme.nuvioTypeScale.labelSm,
color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ softWrap = false,
)
}
}
From d00aba86afe74455661745f7bf0d8403ef03f48f Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 6 May 2026 13:28:43 +0530
Subject: [PATCH 03/18] feat: adding trakt watchprogress option to choose
between trakt/nuvio as preferred
---
.../kotlin/com/nuvio/app/MainActivity.kt | 2 +
.../trakt/TraktSettingsStorage.android.kt | 26 ++
.../composeResources/values/strings.xml | 14 +
.../core/storage/LocalAccountDataCleaner.kt | 2 +
.../app/core/sync/ProfileSettingsSync.kt | 10 +
.../com/nuvio/app/features/home/HomeScreen.kt | 60 ++-
.../features/profiles/ProfileRepository.kt | 2 +
.../app/features/settings/SettingsScreen.kt | 12 +
.../features/settings/TraktSettingsPage.kt | 364 +++++++++++++++++-
.../features/trakt/TraktSettingsRepository.kt | 135 +++++++
.../features/trakt/TraktSettingsStorage.kt | 6 +
.../watchprogress/WatchProgressRepository.kt | 43 ++-
.../nuvio/app/features/home/HomeScreenTest.kt | 69 +++-
.../trakt/TraktSettingsRepositoryTest.kt | 37 ++
.../trakt/TraktSettingsStorage.ios.kt | 15 +
15 files changed, 772 insertions(+), 25 deletions(-)
create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.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 c4124d0d..e899b044 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -35,6 +35,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
+import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage
@@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
+ TraktSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt
new file mode 100644
index 00000000..35f23eb7
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt
@@ -0,0 +1,26 @@
+package com.nuvio.app.features.trakt
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.nuvio.app.core.storage.ProfileScopedKey
+
+internal actual object TraktSettingsStorage {
+ private const val preferencesName = "nuvio_trakt_settings"
+ private const val payloadKey = "trakt_settings_payload"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadPayload(): String? =
+ preferences?.getString(ProfileScopedKey.of(payloadKey), null)
+
+ actual fun savePayload(payload: String) {
+ preferences
+ ?.edit()
+ ?.putString(ProfileScopedKey.of(payloadKey), payload)
+ ?.apply()
+ }
+}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 43f04cb0..04177a28 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -783,6 +783,20 @@
Open Trakt Login
Your Save actions can now target Trakt watchlist and personal lists.
Sign in with Trakt to enable list-based saving and Trakt library mode.
+ Watch Progress
+ Choose which progress source powers resume and continue watching
+ Watch Progress
+ Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active.
+ Trakt
+ Nuvio Sync
+ Watch progress source set to Trakt
+ Watch progress source set to Nuvio Sync
+ Continue Watching Window
+ Trakt history considered for continue watching
+ Continue Watching Window
+ Choose how much Trakt activity should appear in continue watching.
+ All history
+ %1$d days
Audience Score
IMDb
Letterboxd
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
index 96e2a31e..603fce83 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt
@@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
+import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
+ TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()
CatalogRepository.clear()
StreamsRepository.clear()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
index a56aefb8..cdbd477a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt
@@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings
+import com.nuvio.app.features.trakt.TraktSettingsStorage
+import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest
@@ -156,6 +158,7 @@ object ProfileSettingsSync {
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
+ TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
)
@@ -199,6 +202,7 @@ object ProfileSettingsSync {
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
+ traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
notificationsSettings = NotificationsSettingsPayload(
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
@@ -230,6 +234,9 @@ object ProfileSettingsSync {
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged()
+ TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
+ TraktSettingsRepository.onProfileChanged()
+
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
TraktCommentsSettings.onProfileChanged()
@@ -244,6 +251,7 @@ object ProfileSettingsSync {
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
+ TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
@@ -263,6 +271,7 @@ object ProfileSettingsSync {
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
+ "trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
).joinToString(separator = "||")
@@ -283,6 +292,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
+ @SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
)
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 82659478..644295bd 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
@@ -29,6 +29,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.trakt.TraktAuthRepository
+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.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
@@ -87,6 +91,10 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
+ val traktSettingsUiState by remember {
+ TraktSettingsRepository.ensureLoaded()
+ TraktSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
@@ -114,17 +122,31 @@ fun HomeScreen(
}
}
- val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
- if (!isTraktAuthenticated) {
- watchProgressUiState.entries
- } else {
- val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L)
- watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
- }
+ val isTraktProgressActive = remember(
+ isTraktAuthenticated,
+ traktSettingsUiState.watchProgressSource,
+ ) {
+ shouldUseTraktProgress(
+ isAuthenticated = isTraktAuthenticated,
+ source = traktSettingsUiState.watchProgressSource,
+ )
}
- val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) {
- if (isTraktAuthenticated) emptyList() else watchedUiState.items
+ val effectiveWatchProgressEntries = remember(
+ watchProgressUiState.entries,
+ isTraktProgressActive,
+ traktSettingsUiState.continueWatchingDaysCap,
+ ) {
+ filterEntriesForTraktContinueWatchingWindow(
+ entries = watchProgressUiState.entries,
+ isTraktProgressActive = isTraktProgressActive,
+ daysCap = traktSettingsUiState.continueWatchingDaysCap,
+ nowEpochMs = WatchProgressClock.nowEpochMs(),
+ )
+ }
+
+ val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
+ if (isTraktProgressActive) emptyList() else watchedUiState.items
}
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
@@ -242,7 +264,7 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections)
}
- LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
+ LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
return@LaunchedEffect
@@ -263,7 +285,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate,
- showUnairedNextUp = isTraktAuthenticated,
+ showUnairedNextUp = isTraktProgressActive,
) ?: return@withPermit null
val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode)
@@ -525,7 +547,21 @@ fun HomeScreen(
}
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
-private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
+private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
+
+internal fun filterEntriesForTraktContinueWatchingWindow(
+ entries: List,
+ isTraktProgressActive: Boolean,
+ daysCap: Int,
+ nowEpochMs: Long,
+): List {
+ if (!isTraktProgressActive) return entries
+ val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
+ if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
+
+ val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
+ return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
+}
private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index 07a9d9c6..1fec7c1e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -20,6 +20,7 @@ import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
+import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@@ -136,6 +137,7 @@ object ProfileRepository {
persist()
WatchedRepository.onProfileChanged(profileIndex)
LibraryRepository.onProfileChanged(profileIndex)
+ TraktSettingsRepository.onProfileChanged()
WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex)
if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index 6c80adb8..2ed86c15 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -56,6 +56,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
+import com.nuvio.app.features.trakt.TraktSettingsRepository
+import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.tmdb.TmdbSettings
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@@ -109,6 +111,10 @@ fun SettingsScreen(
TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled
}.collectAsStateWithLifecycle()
+ val traktSettingsUiState by remember {
+ TraktSettingsRepository.ensureLoaded()
+ TraktSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
@@ -191,6 +197,7 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
+ traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
@@ -231,6 +238,7 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
+ traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
@@ -281,6 +289,7 @@ private fun MobileSettingsScreen(
mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
+ traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenItems: List,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
@@ -409,6 +418,7 @@ private fun MobileSettingsScreen(
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
+ settingsUiState = traktSettingsUiState,
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)
@@ -446,6 +456,7 @@ private fun TabletSettingsScreen(
mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
+ traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenItems: List,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
@@ -645,6 +656,7 @@ private fun TabletSettingsScreen(
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = true,
uiState = traktAuthUiState,
+ settingsUiState = traktSettingsUiState,
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
index 82130875..76f3aa35 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
@@ -1,31 +1,56 @@
package com.nuvio.app.features.settings
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListScope
+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.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktConnectionMode
+import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
+import com.nuvio.app.features.trakt.TraktSettingsRepository
+import com.nuvio.app.features.trakt.TraktSettingsUiState
+import com.nuvio.app.features.trakt.WatchProgressSource
+import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
+import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.traktBrandPainter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
+import nuvio.composeapp.generated.resources.settings_playback_dialog_close
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
import nuvio.composeapp.generated.resources.settings_trakt_authentication
import nuvio.composeapp.generated.resources.settings_trakt_comments
@@ -42,11 +67,26 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
import nuvio.composeapp.generated.resources.settings_trakt_open_login
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
+import nuvio.composeapp.generated.resources.trakt_all_history
+import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle
+import nuvio.composeapp.generated.resources.trakt_continue_watching_window
+import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
+import nuvio.composeapp.generated.resources.trakt_cw_window_title
+import nuvio.composeapp.generated.resources.trakt_days_format
+import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
+import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
+import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
+import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio
+import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt
+import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle
+import nuvio.composeapp.generated.resources.trakt_watch_progress_title
+import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.traktSettingsContent(
isTablet: Boolean,
uiState: TraktAuthUiState,
+ settingsUiState: TraktSettingsUiState,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
@@ -77,12 +117,326 @@ internal fun LazyListScope.traktSettingsContent(
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
- SettingsSwitchRow(
- title = stringResource(Res.string.settings_trakt_comments),
- description = stringResource(Res.string.settings_trakt_comments_description),
- checked = commentsEnabled,
+ TraktFeatureRows(
isTablet = isTablet,
- onCheckedChange = onCommentsEnabledChange,
+ settingsUiState = settingsUiState,
+ commentsEnabled = commentsEnabled,
+ onCommentsEnabledChange = onCommentsEnabledChange,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TraktFeatureRows(
+ isTablet: Boolean,
+ settingsUiState: TraktSettingsUiState,
+ commentsEnabled: Boolean,
+ onCommentsEnabledChange: (Boolean) -> Unit,
+) {
+ var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
+ var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
+ var statusMessage by rememberSaveable { mutableStateOf(null) }
+
+ val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
+ val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
+ val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
+ val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
+
+ TraktSettingsActionRow(
+ title = stringResource(Res.string.trakt_watch_progress_title),
+ description = stringResource(Res.string.trakt_watch_progress_subtitle),
+ value = watchProgressValue,
+ isTablet = isTablet,
+ onClick = { showWatchProgressDialog = true },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ TraktSettingsActionRow(
+ title = stringResource(Res.string.trakt_continue_watching_window),
+ description = stringResource(Res.string.trakt_continue_watching_subtitle),
+ value = continueWatchingWindowValue,
+ isTablet = isTablet,
+ onClick = { showContinueWatchingWindowDialog = true },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_trakt_comments),
+ description = stringResource(Res.string.settings_trakt_comments_description),
+ checked = commentsEnabled,
+ isTablet = isTablet,
+ onCheckedChange = onCommentsEnabledChange,
+ )
+ statusMessage?.takeIf { it.isNotBlank() }?.let { message ->
+ SettingsGroupDivider(isTablet = isTablet)
+ TraktInfoRow(
+ isTablet = isTablet,
+ text = message,
+ )
+ }
+
+ if (showWatchProgressDialog) {
+ WatchProgressSourceDialog(
+ selectedSource = settingsUiState.watchProgressSource,
+ onSourceSelected = { source ->
+ TraktSettingsRepository.setWatchProgressSource(source)
+ statusMessage = if (source == WatchProgressSource.TRAKT) {
+ traktSelectedMessage
+ } else {
+ nuvioSelectedMessage
+ }
+ showWatchProgressDialog = false
+ },
+ onDismiss = { showWatchProgressDialog = false },
+ )
+ }
+
+ if (showContinueWatchingWindowDialog) {
+ ContinueWatchingWindowDialog(
+ selectedDaysCap = settingsUiState.continueWatchingDaysCap,
+ onDaysCapSelected = { days ->
+ TraktSettingsRepository.setContinueWatchingDaysCap(days)
+ showContinueWatchingWindowDialog = false
+ },
+ onDismiss = { showContinueWatchingWindowDialog = false },
+ )
+ }
+}
+
+@Composable
+private fun TraktSettingsActionRow(
+ title: String,
+ description: String,
+ value: String,
+ isTablet: Boolean,
+ onClick: () -> Unit,
+) {
+ val verticalPadding = if (isTablet) 16.dp else 14.dp
+ val horizontalPadding = if (isTablet) 20.dp else 16.dp
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = horizontalPadding, vertical = verticalPadding),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 12.dp)
+ .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified),
+ 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,
+ )
+ }
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun TraktInfoRow(
+ isTablet: Boolean,
+ text: String,
+) {
+ val horizontalPadding = if (isTablet) 20.dp else 16.dp
+ val verticalPadding = if (isTablet) 14.dp else 12.dp
+
+ Text(
+ text = text,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = horizontalPadding, vertical = verticalPadding),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+}
+
+@Composable
+private fun watchProgressSourceLabel(source: WatchProgressSource): String =
+ when (source) {
+ WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt)
+ WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio)
+ }
+
+@Composable
+private fun continueWatchingDaysCapLabel(daysCap: Int): String {
+ val normalized = normalizeTraktContinueWatchingDaysCap(daysCap)
+ return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
+ stringResource(Res.string.trakt_all_history)
+ } else {
+ stringResource(Res.string.trakt_days_format, normalized)
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun WatchProgressSourceDialog(
+ selectedSource: WatchProgressSource,
+ onSourceSelected: (WatchProgressSource) -> 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 = stringResource(Res.string.trakt_watch_progress_dialog_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source ->
+ TraktDialogOption(
+ label = watchProgressSourceLabel(source),
+ selected = source == selectedSource,
+ onClick = { onSourceSelected(source) },
+ )
+ }
+ }
+
+ 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 ContinueWatchingWindowDialog(
+ selectedDaysCap: Int,
+ onDaysCapSelected: (Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap)
+
+ 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 = stringResource(Res.string.trakt_cw_window_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(Res.string.trakt_cw_window_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TraktContinueWatchingDaysOptions.forEach { days ->
+ val normalizedDays = normalizeTraktContinueWatchingDaysCap(days)
+ TraktDialogOption(
+ label = continueWatchingDaysCapLabel(days),
+ selected = normalizedDays == normalizedSelected,
+ onClick = { onDaysCapSelected(days) },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(Res.string.settings_playback_dialog_close),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TraktDialogOption(
+ label: String,
+ selected: Boolean,
+ 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 = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (selected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
new file mode 100644
index 00000000..3f6a66c4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
@@ -0,0 +1,135 @@
+package com.nuvio.app.features.trakt
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0
+const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
+const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7
+const val TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP = 365
+
+val TraktContinueWatchingDaysOptions: List = listOf(
+ 14,
+ 30,
+ TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
+ 90,
+ 180,
+ TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP,
+ TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
+)
+
+@Serializable
+enum class WatchProgressSource {
+ TRAKT,
+ NUVIO_SYNC;
+
+ companion object {
+ fun fromStorage(value: String?): WatchProgressSource =
+ entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE
+ }
+}
+
+val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT
+
+data class TraktSettingsUiState(
+ val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE,
+ val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
+)
+
+@Serializable
+private data class StoredTraktSettings(
+ val watchProgressSource: String? = null,
+ val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
+)
+
+object TraktSettingsRepository {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ private val _uiState = MutableStateFlow(TraktSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var hasLoaded = false
+
+ fun ensureLoaded() {
+ if (hasLoaded) return
+ loadFromDisk()
+ }
+
+ fun onProfileChanged() {
+ loadFromDisk()
+ }
+
+ fun clearLocalState() {
+ hasLoaded = false
+ _uiState.value = TraktSettingsUiState()
+ }
+
+ fun setWatchProgressSource(source: WatchProgressSource) {
+ ensureLoaded()
+ if (_uiState.value.watchProgressSource == source) return
+ _uiState.value = _uiState.value.copy(watchProgressSource = source)
+ persist()
+ }
+
+ fun setContinueWatchingDaysCap(days: Int) {
+ ensureLoaded()
+ val normalized = normalizeTraktContinueWatchingDaysCap(days)
+ if (_uiState.value.continueWatchingDaysCap == normalized) return
+ _uiState.value = _uiState.value.copy(continueWatchingDaysCap = normalized)
+ persist()
+ }
+
+ private fun loadFromDisk() {
+ hasLoaded = true
+
+ val payload = TraktSettingsStorage.loadPayload().orEmpty().trim()
+ if (payload.isEmpty()) {
+ _uiState.value = TraktSettingsUiState()
+ return
+ }
+
+ val stored = runCatching {
+ json.decodeFromString(payload)
+ }.getOrNull()
+
+ _uiState.value = if (stored != null) {
+ TraktSettingsUiState(
+ watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource),
+ continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap),
+ )
+ } else {
+ TraktSettingsUiState()
+ }
+ }
+
+ private fun persist() {
+ TraktSettingsStorage.savePayload(
+ json.encodeToString(
+ StoredTraktSettings(
+ watchProgressSource = _uiState.value.watchProgressSource.name,
+ continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap,
+ ),
+ ),
+ )
+ }
+}
+
+fun normalizeTraktContinueWatchingDaysCap(days: Int): Int =
+ if (days == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
+ TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
+ } else {
+ days.coerceIn(TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP, TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP)
+ }
+
+fun shouldUseTraktProgress(
+ isAuthenticated: Boolean,
+ source: WatchProgressSource,
+): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt
new file mode 100644
index 00000000..f1302794
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt
@@ -0,0 +1,6 @@
+package com.nuvio.app.features.trakt
+
+internal expect object TraktSettingsStorage {
+ fun loadPayload(): String?
+ fun savePayload(payload: String)
+}
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 55adcebe..d46c40c6 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
@@ -7,6 +7,8 @@ import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
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.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
@@ -37,7 +39,11 @@ object WatchProgressRepository {
init {
syncScope.launch {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
- if (authenticated) {
+ if (shouldUseTraktProgressSource(
+ isAuthenticated = authenticated,
+ source = TraktSettingsRepository.uiState.value.watchProgressSource,
+ )
+ ) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } }
}
@@ -45,9 +51,23 @@ object WatchProgressRepository {
}
}
+ syncScope.launch {
+ TraktSettingsRepository.uiState.collectLatest { settings ->
+ if (shouldUseTraktProgressSource(
+ isAuthenticated = TraktAuthRepository.isAuthenticated.value,
+ source = settings.watchProgressSource,
+ )
+ ) {
+ runCatching { TraktProgressRepository.refreshNow() }
+ .onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } }
+ }
+ publish()
+ }
+ }
+
syncScope.launch {
TraktProgressRepository.uiState.collectLatest {
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (shouldUseTraktProgress()) {
publish()
}
}
@@ -56,19 +76,21 @@ object WatchProgressRepository {
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
+ TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId)
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (shouldUseTraktProgress()) {
TraktProgressRepository.refreshAsync()
}
}
fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return
+ TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId)
TraktProgressRepository.onProfileChanged()
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (shouldUseTraktProgress()) {
TraktProgressRepository.refreshAsync()
}
}
@@ -79,6 +101,7 @@ object WatchProgressRepository {
currentProfileId = 1
entriesByVideoId.clear()
TraktProgressRepository.clearLocalState()
+ TraktSettingsRepository.clearLocalState()
_uiState.value = WatchProgressUiState()
}
@@ -98,9 +121,12 @@ object WatchProgressRepository {
}
suspend fun pullFromServer(profileId: Int) {
+ TraktAuthRepository.ensureLoaded()
+ TraktSettingsRepository.ensureLoaded()
+ TraktProgressRepository.ensureLoaded()
currentProfileId = profileId
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (shouldUseTraktProgress()) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish()
@@ -368,7 +394,6 @@ object WatchProgressRepository {
}
private fun pushScrobbleToServer(entry: WatchProgressEntry) {
- if (shouldUseTraktProgress()) return
syncScope.launch {
runCatching {
val profileId = ProfileRepository.activeProfileId
@@ -406,7 +431,11 @@ object WatchProgressRepository {
)
}
- private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value
+ private fun shouldUseTraktProgress(): Boolean =
+ shouldUseTraktProgressSource(
+ isAuthenticated = TraktAuthRepository.isAuthenticated.value,
+ source = TraktSettingsRepository.uiState.value.watchProgressSource,
+ )
private fun currentEntries(): List {
return if (shouldUseTraktProgress()) {
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt
index 849211a7..51da33ff 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt
@@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
+import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -60,6 +61,68 @@ class HomeScreenTest {
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
}
+ @Test
+ fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
+ val oldEntry = progressEntry(
+ videoId = "old",
+ title = "Old",
+ lastUpdatedEpochMs = 1_000L,
+ seasonNumber = null,
+ episodeNumber = null,
+ )
+ val recentEntry = progressEntry(
+ videoId = "recent",
+ title = "Recent",
+ lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
+ seasonNumber = null,
+ episodeNumber = null,
+ )
+ val entries = listOf(oldEntry, recentEntry)
+
+ val filtered = filterEntriesForTraktContinueWatchingWindow(
+ entries = entries,
+ isTraktProgressActive = true,
+ daysCap = 60,
+ nowEpochMs = 90L * MILLIS_PER_DAY,
+ )
+ val nuvioSource = filterEntriesForTraktContinueWatchingWindow(
+ entries = entries,
+ isTraktProgressActive = false,
+ daysCap = 60,
+ nowEpochMs = 90L * MILLIS_PER_DAY,
+ )
+
+ assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId))
+ assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId))
+ }
+
+ @Test
+ fun `Trakt all history window keeps old progress`() {
+ val oldEntry = progressEntry(
+ videoId = "old",
+ title = "Old",
+ lastUpdatedEpochMs = 1_000L,
+ seasonNumber = null,
+ episodeNumber = null,
+ )
+ val recentEntry = progressEntry(
+ videoId = "recent",
+ title = "Recent",
+ lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
+ seasonNumber = null,
+ episodeNumber = null,
+ )
+
+ val result = filterEntriesForTraktContinueWatchingWindow(
+ entries = listOf(oldEntry, recentEntry),
+ isTraktProgressActive = true,
+ daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
+ nowEpochMs = 90L * MILLIS_PER_DAY,
+ )
+
+ assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
+ }
+
private fun progressEntry(
videoId: String,
title: String,
@@ -100,4 +163,8 @@ class HomeScreenTest {
durationMs = 0L,
progressFraction = 0f,
)
-}
\ No newline at end of file
+
+ private companion object {
+ const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
new file mode 100644
index 00000000..32928ef7
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
@@ -0,0 +1,37 @@
+package com.nuvio.app.features.trakt
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TraktSettingsRepositoryTest {
+
+ @Test
+ fun `watch progress source defaults to Trakt for unset or invalid storage`() {
+ assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null))
+ assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(""))
+ assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("not-a-source"))
+ }
+
+ @Test
+ fun `watch progress source restores valid storage values`() {
+ assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT"))
+ assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC"))
+ }
+
+ @Test
+ fun `continue watching cap normalizes finite windows and all history`() {
+ assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0))
+ assertEquals(7, normalizeTraktContinueWatchingDaysCap(1))
+ assertEquals(60, normalizeTraktContinueWatchingDaysCap(60))
+ assertEquals(365, normalizeTraktContinueWatchingDaysCap(999))
+ }
+
+ @Test
+ fun `Trakt progress is active only when authenticated and selected`() {
+ assertFalse(shouldUseTraktProgress(isAuthenticated = false, source = WatchProgressSource.TRAKT))
+ assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC))
+ assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT))
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt
new file mode 100644
index 00000000..06c60535
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt
@@ -0,0 +1,15 @@
+package com.nuvio.app.features.trakt
+
+import com.nuvio.app.core.storage.ProfileScopedKey
+import platform.Foundation.NSUserDefaults
+
+internal actual object TraktSettingsStorage {
+ private const val payloadKey = "trakt_settings_payload"
+
+ actual fun loadPayload(): String? =
+ NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
+
+ actual fun savePayload(payload: String) {
+ NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
+ }
+}
From 55b97d97adfe0d978874295fc880af4163a0fce2 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 6 May 2026 13:52:49 +0530
Subject: [PATCH 04/18] feat: trakt library source option to switch between
trakt/nuvio library
---
...PlatformLocalAccountDataCleaner.android.kt | 1 +
.../composeResources/values/strings.xml | 8 +
.../commonMain/kotlin/com/nuvio/app/App.kt | 14 +-
.../app/features/details/MetaDetailsScreen.kt | 49 ++++---
.../details/components/DetailActionButtons.kt | 64 ++++----
.../app/features/library/LibraryRepository.kt | 137 ++++++++++++++----
.../features/profiles/ProfileRepository.kt | 2 +-
.../features/settings/TraktSettingsPage.kt | 105 +++++++++++++-
.../features/trakt/TraktSettingsRepository.kt | 31 ++++
.../features/library/LibraryRepositoryTest.kt | 32 ++++
.../trakt/TraktSettingsRepositoryTest.kt | 30 ++++
.../PlatformLocalAccountDataCleaner.ios.kt | 1 +
12 files changed, 381 insertions(+), 93 deletions(-)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
index 7f970d32..de84c4a5 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt
@@ -16,6 +16,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings",
"nuvio_trakt_auth",
"nuvio_trakt_library",
+ "nuvio_trakt_settings",
"nuvio_watched",
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 04177a28..5c657824 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -783,6 +783,14 @@
Open Trakt Login
Your Save actions can now target Trakt watchlist and personal lists.
Sign in with Trakt to enable list-based saving and Trakt library mode.
+ Library Source
+ Choose which library to use for saving and viewing your collection
+ Library Source
+ Choose where to save and manage your library items
+ Trakt
+ Nuvio Library
+ Trakt library selected
+ Nuvio library selected
Watch Progress
Choose which progress source powers resume and continue watching
Watch Progress
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index f9e85f6c..eea60cd6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -152,8 +152,6 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository
-import com.nuvio.app.features.trakt.TraktAuthRepository
-import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController
@@ -486,10 +484,6 @@ private fun MainAppContent(
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
}.collectAsStateWithLifecycle()
- val traktAuthUiState by remember {
- TraktAuthRepository.ensureLoaded()
- TraktAuthRepository.uiState
- }.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val playerSettingsUiState by remember {
@@ -508,7 +502,7 @@ private fun MainAppContent(
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
- val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
+ val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
@@ -1664,12 +1658,12 @@ private fun MainAppContent(
onToggleLibrary = {
selectedPosterForActions?.let { preview ->
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
- if (!isTraktConnected) {
+ if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
- pickerTabs = LibraryRepository.traktListTabs()
+ pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
@@ -1677,7 +1671,7 @@ private fun MainAppContent(
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
- val tabs = LibraryRepository.traktListTabs()
+ val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
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 0161bba5..b4f31fe6 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
@@ -276,39 +276,39 @@ fun MetaDetailsScreen(
val isSaved = remember(
libraryUiState.items,
libraryUiState.sections,
- traktAuthUiState.mode,
+ libraryUiState.sourceMode,
meta.id,
meta.type,
) {
LibraryRepository.isSaved(meta.id, meta.type)
}
- val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
- val toggleSaved = remember(meta, isTraktConnected) {
+ val openLibraryListPicker = remember(meta) {
{
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
- if (!isTraktConnected) {
- LibraryRepository.toggleSaved(libraryItem)
- } else {
- pickerTabs = LibraryRepository.traktListTabs()
- pickerMembership = pickerTabs.associate { it.key to false }
- pickerPending = true
- pickerError = null
- showLibraryListPicker = true
- detailsScope.launch {
- runCatching {
- val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
- val tabs = LibraryRepository.traktListTabs()
- pickerTabs = tabs
- pickerMembership = tabs.associate { tab ->
- tab.key to (snapshot[tab.key] == true)
- }
- }.onFailure { error ->
- pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
+ pickerTabs = LibraryRepository.libraryListTabs()
+ pickerMembership = pickerTabs.associate { it.key to false }
+ pickerPending = true
+ pickerError = null
+ showLibraryListPicker = true
+ detailsScope.launch {
+ runCatching {
+ val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
+ val tabs = LibraryRepository.libraryListTabs()
+ pickerTabs = tabs
+ pickerMembership = tabs.associate { tab ->
+ tab.key to (snapshot[tab.key] == true)
}
- pickerPending = false
+ }.onFailure { error ->
+ pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
}
- Unit
+ pickerPending = false
}
+ Unit
+ }
+ }
+ val toggleSaved = remember(meta) {
+ {
+ LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
@@ -639,6 +639,7 @@ fun MetaDetailsScreen(
onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved,
+ onSaveLongClick = openLibraryListPicker,
showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber,
@@ -946,6 +947,7 @@ private fun ConfiguredMetaSections(
onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit,
+ onSaveLongClick: (() -> Unit)?,
showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?,
preferredEpisodeNumber: Int?,
@@ -1010,6 +1012,7 @@ private fun ConfiguredMetaSections(
onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
onSaveClick = onSaveClick,
+ onSaveLongClick = onSaveLongClick,
)
}
MetaScreenSectionKey.OVERVIEW -> {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt
index 6eb1d515..d5be0d59 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt
@@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -44,6 +41,7 @@ fun DetailActionButtons(
onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {},
+ onSaveLongClick: (() -> Unit)? = null,
) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
@@ -96,35 +94,49 @@ fun DetailActionButtons(
}
}
- OutlinedButton(
- onClick = onSaveClick,
+ Surface(
modifier = rowButtonModifier.height(50.dp),
shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
+ contentColor = MaterialTheme.colorScheme.onSurface,
) {
- if (isSaved) {
- Icon(
- imageVector = Icons.Default.Check,
- contentDescription = null,
- modifier = Modifier.size(20.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- } else {
- Icon(
- painter = libraryAddPainter,
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- tint = MaterialTheme.colorScheme.onSurface,
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onSaveClick,
+ onLongClick = onSaveLongClick,
+ role = Role.Button,
+ )
+ .height(50.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isSaved) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ } else {
+ Icon(
+ painter = libraryAddPainter,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = saveLabel,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
}
- Spacer(modifier = Modifier.width(6.dp))
- Text(
- text = saveLabel,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
index a3983cbf..c93d5caa 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt
@@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktLibraryRepository
+import com.nuvio.app.features.trakt.TraktListTab
+import com.nuvio.app.features.trakt.TraktListType
import com.nuvio.app.features.trakt.TraktMembershipChanges
+import com.nuvio.app.features.trakt.TraktSettingsRepository
+import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
+import com.nuvio.app.features.trakt.shouldUseTraktLibrary
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -65,12 +72,28 @@ object LibraryRepository {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) {
TraktLibraryRepository.preloadListTabsAsync()
- runCatching { TraktLibraryRepository.refreshNow() }
- .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
+ if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
+ runCatching { TraktLibraryRepository.refreshNow() }
+ .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
+ }
}
publish()
}
}
+ syncScope.launch {
+ TraktSettingsRepository.uiState
+ .map { it.librarySourceMode }
+ .distinctUntilChanged()
+ .collectLatest { source ->
+ if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
+ TraktLibraryRepository.preloadListTabsAsync()
+ publish()
+ refreshTraktLibraryAsync()
+ } else {
+ publish()
+ }
+ }
+ }
syncScope.launch {
TraktLibraryRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) {
@@ -82,23 +105,29 @@ object LibraryRepository {
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
+ TraktSettingsRepository.ensureLoaded()
TraktLibraryRepository.ensureLoaded()
if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
- refreshTraktLibraryAsync()
+ if (isTraktLibrarySourceActive()) {
+ refreshTraktLibraryAsync()
+ }
}
}
fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return
+ TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId)
TraktAuthRepository.onProfileChanged()
TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
- refreshTraktLibraryAsync()
+ if (isTraktLibrarySourceActive()) {
+ refreshTraktLibraryAsync()
+ }
}
}
@@ -130,7 +159,7 @@ object LibraryRepository {
suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (isTraktLibrarySourceActive()) {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
publish()
@@ -157,7 +186,7 @@ object LibraryRepository {
fun toggleSaved(item: LibraryItem) {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (isTraktLibrarySourceActive()) {
syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
@@ -175,7 +204,6 @@ object LibraryRepository {
fun save(item: LibraryItem) {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) return
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
publish()
persist()
@@ -184,7 +212,6 @@ object LibraryRepository {
fun remove(id: String) {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) return
if (itemsById.remove(id) != null) {
publish()
persist()
@@ -195,7 +222,7 @@ object LibraryRepository {
fun isSaved(id: String, type: String? = null): Boolean {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (isTraktLibrarySourceActive()) {
if (type != null) {
return TraktLibraryRepository.isInAnyList(id, type)
}
@@ -212,46 +239,65 @@ object LibraryRepository {
fun savedItem(id: String): LibraryItem? {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (isTraktLibrarySourceActive()) {
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
}
return itemsById[id]
}
- fun traktListTabs() = TraktLibraryRepository.currentListTabs()
+ fun libraryListTabs(): List {
+ val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
+ TraktLibraryRepository.currentListTabs()
+ } else {
+ emptyList()
+ }
+ return libraryTabsWithLocal(traktTabs)
+ }
+
+ fun traktListTabs(): List = libraryListTabs()
suspend fun getMembershipSnapshot(item: LibraryItem): Map {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) {
- return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
- }
val inLocal = itemsById.containsKey(item.id)
- return mapOf(LOCAL_LIST_KEY to inLocal)
+ if (TraktAuthRepository.isAuthenticated.value) {
+ val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
+ return libraryMembershipWithLocal(
+ inLocal = inLocal,
+ traktMembership = traktMembership,
+ )
+ }
+ return libraryMembershipWithLocal(inLocal = inLocal)
}
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) {
ensureLoaded()
- if (TraktAuthRepository.isAuthenticated.value) {
- TraktLibraryRepository.applyMembershipChanges(
- item = item,
- changes = TraktMembershipChanges(desiredMembership = desiredMembership),
- )
- publish()
- return
+ val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
+ val currentlyInLocal = itemsById.containsKey(item.id)
+ if (localDesired != currentlyInLocal) {
+ if (localDesired) {
+ save(item)
+ } else {
+ remove(item.id)
+ }
}
- val shouldBeSaved = desiredMembership.values.any { it }
- if (shouldBeSaved) {
- save(item)
+ if (TraktAuthRepository.isAuthenticated.value) {
+ val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
+ if (traktMembership.isNotEmpty()) {
+ TraktLibraryRepository.applyMembershipChanges(
+ item = item,
+ changes = TraktMembershipChanges(desiredMembership = traktMembership),
+ )
+ }
+ publish()
} else {
- remove(item.id)
+ publish()
}
}
private fun pushToServer() {
syncScope.launch {
- if (TraktAuthRepository.isAuthenticated.value) return@launch
runCatching {
val profileId = ProfileRepository.activeProfileId
val syncItems = itemsById.values.map { it.toSyncItem() }
@@ -267,7 +313,7 @@ object LibraryRepository {
}
private fun publish() {
- if (TraktAuthRepository.isAuthenticated.value) {
+ if (isTraktLibrarySourceActive()) {
val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty()
@@ -334,9 +380,42 @@ object LibraryRepository {
publish()
}
}
+
+ private fun selectedLibrarySourceMode(): LibrarySourceMode {
+ TraktSettingsRepository.ensureLoaded()
+ return TraktSettingsRepository.uiState.value.librarySourceMode
+ }
+
+ private fun effectiveLibrarySourceMode(): LibrarySourceMode =
+ resolveEffectiveLibrarySourceMode(
+ isAuthenticated = TraktAuthRepository.isAuthenticated.value,
+ source = selectedLibrarySourceMode(),
+ )
+
+ private fun isTraktLibrarySourceActive(): Boolean =
+ effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
}
-private const val LOCAL_LIST_KEY = "local"
+internal const val LOCAL_LIBRARY_LIST_KEY = "local"
+internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library"
+
+internal fun localLibraryListTab(): TraktListTab =
+ TraktListTab(
+ key = LOCAL_LIBRARY_LIST_KEY,
+ title = LOCAL_LIBRARY_LIST_TITLE,
+ type = TraktListType.WATCHLIST,
+ )
+
+internal fun libraryTabsWithLocal(traktTabs: List): List =
+ listOf(localLibraryListTab()) + traktTabs
+
+internal fun libraryMembershipWithLocal(
+ inLocal: Boolean,
+ traktMembership: Map = emptyMap(),
+): Map =
+ linkedMapOf(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
+ putAll(traktMembership)
+ }
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
index 1fec7c1e..01904938 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt
@@ -136,8 +136,8 @@ object ProfileRepository {
)
persist()
WatchedRepository.onProfileChanged(profileIndex)
- LibraryRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged()
+ LibraryRepository.onProfileChanged(profileIndex)
WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex)
if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
index 76f3aa35..198b3123 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt
@@ -37,6 +37,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.dp
+import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState
@@ -73,6 +74,14 @@ import nuvio.composeapp.generated.resources.trakt_continue_watching_window
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
import nuvio.composeapp.generated.resources.trakt_cw_window_title
import nuvio.composeapp.generated.resources.trakt_days_format
+import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle
+import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title
+import nuvio.composeapp.generated.resources.trakt_library_source_nuvio
+import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected
+import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
+import nuvio.composeapp.generated.resources.trakt_library_source_title
+import nuvio.composeapp.generated.resources.trakt_library_source_trakt
+import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
@@ -136,15 +145,27 @@ private fun TraktFeatureRows(
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
+ var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) }
var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
var statusMessage by rememberSaveable { mutableStateOf(null) }
+ val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
- val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
- val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
+ val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
+ val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
+ val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected)
+ val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected)
+ TraktSettingsActionRow(
+ title = stringResource(Res.string.trakt_library_source_title),
+ description = stringResource(Res.string.trakt_library_source_subtitle),
+ value = librarySourceValue,
+ isTablet = isTablet,
+ onClick = { showLibrarySourceDialog = true },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
TraktSettingsActionRow(
title = stringResource(Res.string.trakt_watch_progress_title),
description = stringResource(Res.string.trakt_watch_progress_subtitle),
@@ -176,15 +197,31 @@ private fun TraktFeatureRows(
)
}
+ if (showLibrarySourceDialog) {
+ LibrarySourceModeDialog(
+ selectedSource = settingsUiState.librarySourceMode,
+ onSourceSelected = { source ->
+ TraktSettingsRepository.setLibrarySourceMode(source)
+ statusMessage = if (source == LibrarySourceMode.TRAKT) {
+ traktLibrarySelectedMessage
+ } else {
+ nuvioLibrarySelectedMessage
+ }
+ showLibrarySourceDialog = false
+ },
+ onDismiss = { showLibrarySourceDialog = false },
+ )
+ }
+
if (showWatchProgressDialog) {
WatchProgressSourceDialog(
selectedSource = settingsUiState.watchProgressSource,
onSourceSelected = { source ->
TraktSettingsRepository.setWatchProgressSource(source)
statusMessage = if (source == WatchProgressSource.TRAKT) {
- traktSelectedMessage
+ traktProgressSelectedMessage
} else {
- nuvioSelectedMessage
+ nuvioProgressSelectedMessage
}
showWatchProgressDialog = false
},
@@ -271,6 +308,13 @@ private fun TraktInfoRow(
)
}
+@Composable
+private fun librarySourceModeLabel(source: LibrarySourceMode): String =
+ when (source) {
+ LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt)
+ LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio)
+ }
+
@Composable
private fun watchProgressSourceLabel(source: WatchProgressSource): String =
when (source) {
@@ -288,6 +332,59 @@ private fun continueWatchingDaysCapLabel(daysCap: Int): String {
}
}
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun LibrarySourceModeDialog(
+ selectedSource: LibrarySourceMode,
+ onSourceSelected: (LibrarySourceMode) -> 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 = stringResource(Res.string.trakt_library_source_dialog_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(Res.string.trakt_library_source_dialog_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source ->
+ TraktDialogOption(
+ label = librarySourceModeLabel(source),
+ selected = source == selectedSource,
+ onClick = { onSourceSelected(source) },
+ )
+ }
+ }
+
+ 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 WatchProgressSourceDialog(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
index 3f6a66c4..ee9cccd4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt
@@ -1,5 +1,6 @@
package com.nuvio.app.features.trakt
+import com.nuvio.app.features.library.LibrarySourceMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -35,16 +36,22 @@ enum class WatchProgressSource {
}
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT
+val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT
+
+fun librarySourceModeFromStorage(value: String?): LibrarySourceMode =
+ LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE
data class TraktSettingsUiState(
val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE,
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
+ val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE,
)
@Serializable
private data class StoredTraktSettings(
val watchProgressSource: String? = null,
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
+ val librarySourceMode: String? = null,
)
object TraktSettingsRepository {
@@ -87,6 +94,13 @@ object TraktSettingsRepository {
persist()
}
+ fun setLibrarySourceMode(mode: LibrarySourceMode) {
+ ensureLoaded()
+ if (_uiState.value.librarySourceMode == mode) return
+ _uiState.value = _uiState.value.copy(librarySourceMode = mode)
+ persist()
+ }
+
private fun loadFromDisk() {
hasLoaded = true
@@ -104,6 +118,7 @@ object TraktSettingsRepository {
TraktSettingsUiState(
watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource),
continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap),
+ librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode),
)
} else {
TraktSettingsUiState()
@@ -116,6 +131,7 @@ object TraktSettingsRepository {
StoredTraktSettings(
watchProgressSource = _uiState.value.watchProgressSource.name,
continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap,
+ librarySourceMode = _uiState.value.librarySourceMode.name,
),
),
)
@@ -133,3 +149,18 @@ fun shouldUseTraktProgress(
isAuthenticated: Boolean,
source: WatchProgressSource,
): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT
+
+fun effectiveLibrarySourceMode(
+ isAuthenticated: Boolean,
+ source: LibrarySourceMode,
+): LibrarySourceMode =
+ if (isAuthenticated && source == LibrarySourceMode.TRAKT) {
+ LibrarySourceMode.TRAKT
+ } else {
+ LibrarySourceMode.LOCAL
+ }
+
+fun shouldUseTraktLibrary(
+ isAuthenticated: Boolean,
+ source: LibrarySourceMode,
+): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt
index b33fe936..f0ac0f9f 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt
@@ -1,6 +1,8 @@
package com.nuvio.app.features.library
import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.trakt.TraktListTab
+import com.nuvio.app.features.trakt.TraktListType
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -37,4 +39,34 @@ class LibraryRepositoryTest {
assertEquals(PosterShape.Poster, preview.posterShape)
assertEquals("banner", preview.banner)
}
+
+ @Test
+ fun `library tabs include local Nuvio library before Trakt tabs`() {
+ val traktTab = TraktListTab(
+ key = "trakt:watchlist",
+ title = "Watchlist",
+ type = TraktListType.WATCHLIST,
+ )
+
+ val tabs = libraryTabsWithLocal(listOf(traktTab))
+
+ assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key })
+ assertEquals("Nuvio Library", tabs.first().title)
+ }
+
+ @Test
+ fun `library membership always includes local state before Trakt membership`() {
+ val membership = libraryMembershipWithLocal(
+ inLocal = true,
+ traktMembership = mapOf("trakt:watchlist" to false),
+ )
+
+ assertEquals(
+ mapOf(
+ "local" to true,
+ "trakt:watchlist" to false,
+ ),
+ membership,
+ )
+ }
}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
index 32928ef7..f504fcc8 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt
@@ -1,5 +1,6 @@
package com.nuvio.app.features.trakt
+import com.nuvio.app.features.library.LibrarySourceMode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -20,6 +21,19 @@ class TraktSettingsRepositoryTest {
assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC"))
}
+ @Test
+ fun `library source defaults to Trakt for unset or invalid storage`() {
+ assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(null))
+ assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(""))
+ assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("not-a-source"))
+ }
+
+ @Test
+ fun `library source restores valid storage values`() {
+ assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT"))
+ assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL"))
+ }
+
@Test
fun `continue watching cap normalizes finite windows and all history`() {
assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0))
@@ -34,4 +48,20 @@ class TraktSettingsRepositoryTest {
assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC))
assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT))
}
+
+ @Test
+ fun `effective library source uses Trakt only when authenticated and selected`() {
+ assertEquals(
+ LibrarySourceMode.LOCAL,
+ effectiveLibrarySourceMode(isAuthenticated = false, source = LibrarySourceMode.TRAKT),
+ )
+ assertEquals(
+ LibrarySourceMode.LOCAL,
+ effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.LOCAL),
+ )
+ assertEquals(
+ LibrarySourceMode.TRAKT,
+ effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT),
+ )
+ }
}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
index 8e8a1418..71d71168 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt
@@ -45,6 +45,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_audience",
"trakt_auth_payload",
"trakt_library_payload",
+ "trakt_settings_payload",
)
actual fun wipe() {
From 2af53f416d4a6723bad7cc7ae1acb0e12c3fc1bc Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 6 May 2026 14:31:13 +0530
Subject: [PATCH 05/18] feat: blur unwatched episode
---
.../composeResources/values/strings.xml | 4 ++
.../app/features/details/MetaDetailsScreen.kt | 3 ++
.../details/MetaScreenSettingsRepository.kt | 20 ++++++++++
.../details/components/DetailSeriesContent.kt | 22 ++++++++--
.../com/nuvio/app/features/home/HomeScreen.kt | 36 +++++++++++++++--
.../components/HomeContinueWatchingSection.kt | 33 +++++++++++++--
.../features/player/PlayerEpisodesPanel.kt | 40 ++++++++++++++++++-
.../nuvio/app/features/player/PlayerScreen.kt | 19 +++++++++
.../settings/ContinueWatchingSettingsPage.kt | 11 +++++
.../settings/MetaScreenSettingsPage.kt | 10 +++++
.../settings/SettingsFullScreenPages.kt | 1 +
.../app/features/settings/SettingsScreen.kt | 2 +
.../ContinueWatchingPreferencesRepository.kt | 13 ++++++
.../watchprogress/WatchProgressModels.kt | 1 +
.../nuvio/app/features/home/HomeScreenTest.kt | 23 +++++++++++
15 files changed, 226 insertions(+), 12 deletions(-)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 5c657824..b6f26e87 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -506,6 +506,8 @@
Show value
Show a popup to continue where you left off when opening the app after leaving from the player.
Resume prompt on launch
+ Blur next episode thumbnails in Continue Watching to avoid spoilers.
+ Blur Unwatched in Continue Watching
Poster Card Style
ON LAUNCH
UP NEXT BEHAVIOR
@@ -557,6 +559,8 @@
Detail-first stacked cards
Episodes
Seasons and episode list for series.
+ Blur Unwatched Episodes
+ Blur episode thumbnails until watched to avoid spoilers.
Group %1$d
More like this
TMDB recommendation backdrops on detail page
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 b4f31fe6..80c724a3 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
@@ -690,6 +690,7 @@ fun MetaDetailsScreen(
onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
+ blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta,
@@ -970,6 +971,7 @@ private fun ConfiguredMetaSections(
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map,
watchedKeys: Set,
+ blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
@@ -1062,6 +1064,7 @@ private fun ConfiguredMetaSections(
episodeCardStyle = settings.episodeCardStyle,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt
index 22f1d1eb..8d4f8c0f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt
@@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false,
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
+ val blurUnwatchedEpisodes: Boolean = false,
)
enum class MetaEpisodeCardStyle {
@@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
@SerialName("tvStyleLayout")
val tabLayout: Boolean = false,
val episodeCardStyle: String = "horizontal",
+ @SerialName("blur_unwatched_episodes")
+ val blurUnwatchedEpisodes: Boolean = false,
)
private data class MetaScreenSectionDefinition(
@@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() {
@@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
?: MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item
@@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState()
ensureLoaded()
}
@@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
persist()
}
+ fun setBlurUnwatchedEpisodes(enabled: Boolean) {
+ ensureLoaded()
+ blurUnwatchedEpisodes = enabled
+ publish()
+ persist()
+ }
+
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded()
if (!key.canBeTabbed) return
@@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
preferences.clear()
cinematicBackground = false
tabLayout = false
+ episodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState()
}
@@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
cinematicBackground: Boolean,
tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
+ blurUnwatchedEpisodes: Boolean = false,
) {
ensureLoaded()
this.cinematicBackground = cinematicBackground
this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle
+ this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
preferences = items.associate { item ->
item.key to StoredMetaScreenSectionPreference(
key = item.key.name,
@@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = false
normalizePreferences()
publish()
persist()
@@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
)
}
@@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
),
),
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
index 485c729a..10f42141 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
@@ -45,6 +45,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -90,6 +91,7 @@ fun DetailSeriesContent(
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
progressByVideoId: Map = emptyMap(),
watchedKeys: Set = emptySet(),
+ blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
) {
@@ -276,6 +278,7 @@ fun DetailSeriesContent(
watchedKeys = watchedKeys,
fallbackImage = meta.background ?: meta.poster,
progressByVideoId = progressByVideoId,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
preferredEpisodeNumber = preferredEpisodeNumber,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
@@ -295,13 +298,14 @@ fun DetailSeriesContent(
video = episode,
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
- isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
+ isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
),
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
@@ -553,6 +557,7 @@ private fun EpisodeHorizontalRow(
watchedKeys: Set,
fallbackImage: String?,
progressByVideoId: Map,
+ blurUnwatchedEpisodes: Boolean,
preferredEpisodeNumber: Int? = null,
onEpisodeClick: ((MetaVideo) -> Unit)?,
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
@@ -597,13 +602,14 @@ private fun EpisodeHorizontalRow(
video = episode,
fallbackImage = fallbackImage,
progressEntry = progressByVideoId[episodeVideoId],
- isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
+ isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = metaType,
metaId = parentMetaId,
episode = episode,
),
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
metrics = rowMetrics,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
@@ -619,6 +625,7 @@ private fun EpisodeHorizontalCard(
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
isWatched: Boolean,
+ blurUnwatchedEpisodes: Boolean,
metrics: EpisodeHorizontalCardMetrics,
onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null,
@@ -642,11 +649,14 @@ private fun EpisodeHorizontalCard(
),
) {
val imageUrl = video.thumbnail ?: fallbackImage
+ val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
}
@@ -889,6 +899,7 @@ private fun EpisodeListCard(
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
isWatched: Boolean,
+ blurUnwatchedEpisodes: Boolean,
sizing: SeriesContentSizing,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
@@ -923,11 +934,14 @@ private fun EpisodeListCard(
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) {
val imageUrl = video.thumbnail ?: fallbackImage
+ val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
} else {
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 644295bd..d0144ead 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
@@ -166,6 +166,9 @@ fun HomeScreen(
)
}
}
+ val completedSeriesContentIds = remember(completedSeriesCandidates) {
+ completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
+ }
val visibleContinueWatchingEntries = remember(
effectiveWatchProgressEntries,
latestCompletedBySeries,
@@ -181,8 +184,21 @@ fun HomeScreen(
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf