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] 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() {