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