feat: trakt library source option to switch between trakt/nuvio library

This commit is contained in:
tapframe 2026-05-06 13:52:49 +05:30
parent d00aba86af
commit 55b97d97ad
12 changed files with 381 additions and 93 deletions

View file

@ -16,6 +16,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings", "nuvio_mdblist_settings",
"nuvio_trakt_auth", "nuvio_trakt_auth",
"nuvio_trakt_library", "nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched", "nuvio_watched",
"nuvio_stream_link_cache", "nuvio_stream_link_cache",
"nuvio_continue_watching_preferences", "nuvio_continue_watching_preferences",

View file

@ -783,6 +783,14 @@
<string name="settings_trakt_open_login">Open Trakt Login</string> <string name="settings_trakt_open_login">Open Trakt Login</string>
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string> <string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string> <string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
<string name="trakt_library_source_title">Library Source</string>
<string name="trakt_library_source_subtitle">Choose which library to use for saving and viewing your collection</string>
<string name="trakt_library_source_dialog_title">Library Source</string>
<string name="trakt_library_source_dialog_subtitle">Choose where to save and manage your library items</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Nuvio Library</string>
<string name="trakt_library_source_trakt_selected">Trakt library selected</string>
<string name="trakt_library_source_nuvio_selected">Nuvio library selected</string>
<string name="trakt_watch_progress_title">Watch Progress</string> <string name="trakt_watch_progress_title">Watch Progress</string>
<string name="trakt_watch_progress_subtitle">Choose which progress source powers resume and continue watching</string> <string name="trakt_watch_progress_subtitle">Choose which progress source powers resume and continue watching</string>
<string name="trakt_watch_progress_dialog_title">Watch Progress</string> <string name="trakt_watch_progress_dialog_title">Watch Progress</string>

View file

@ -152,8 +152,6 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository 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.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -486,10 +484,6 @@ private fun MainAppContent(
LibraryRepository.ensureLoaded() LibraryRepository.ensureLoaded()
LibraryRepository.uiState LibraryRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val playerSettingsUiState by remember { val playerSettingsUiState by remember {
@ -508,7 +502,7 @@ private fun MainAppContent(
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) 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 initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
@ -1664,12 +1658,12 @@ private fun MainAppContent(
onToggleLibrary = { onToggleLibrary = {
selectedPosterForActions?.let { preview -> selectedPosterForActions?.let { preview ->
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) { if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem) LibraryRepository.toggleSaved(libraryItem)
} else { } else {
pickerItem = libraryItem pickerItem = libraryItem
pickerTitle = preview.name pickerTitle = preview.name
pickerTabs = LibraryRepository.traktListTabs() pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false } pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true pickerPending = true
pickerError = null pickerError = null
@ -1677,7 +1671,7 @@ private fun MainAppContent(
coroutineScope.launch { coroutineScope.launch {
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs pickerTabs = tabs
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)

View file

@ -276,20 +276,16 @@ fun MetaDetailsScreen(
val isSaved = remember( val isSaved = remember(
libraryUiState.items, libraryUiState.items,
libraryUiState.sections, libraryUiState.sections,
traktAuthUiState.mode, libraryUiState.sourceMode,
meta.id, meta.id,
meta.type, meta.type,
) { ) {
LibraryRepository.isSaved(meta.id, meta.type) LibraryRepository.isSaved(meta.id, meta.type)
} }
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val openLibraryListPicker = remember(meta) {
val toggleSaved = remember(meta, isTraktConnected) {
{ {
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L) val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) { pickerTabs = LibraryRepository.libraryListTabs()
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerTabs = LibraryRepository.traktListTabs()
pickerMembership = pickerTabs.associate { it.key to false } pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true pickerPending = true
pickerError = null pickerError = null
@ -297,7 +293,7 @@ fun MetaDetailsScreen(
detailsScope.launch { detailsScope.launch {
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs pickerTabs = tabs
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)
@ -310,6 +306,10 @@ fun MetaDetailsScreen(
Unit Unit
} }
} }
val toggleSaved = remember(meta) {
{
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
} }
val movieProgress = watchProgressUiState.byVideoId[meta.id] val movieProgress = watchProgressUiState.byVideoId[meta.id]
?.takeUnless { it.isCompleted } ?.takeUnless { it.isCompleted }
@ -639,6 +639,7 @@ fun MetaDetailsScreen(
onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved, onSaveClick = toggleSaved,
onSaveLongClick = openLibraryListPicker,
showManualPlayOption = showManualPlayOption, showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber, preferredEpisodeNumber = seriesAction?.episodeNumber,
@ -946,6 +947,7 @@ private fun ConfiguredMetaSections(
onPrimaryPlayClick: () -> Unit, onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?, onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onSaveLongClick: (() -> Unit)?,
showManualPlayOption: Boolean, showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?, preferredEpisodeSeasonNumber: Int?,
preferredEpisodeNumber: Int?, preferredEpisodeNumber: Int?,
@ -1010,6 +1012,7 @@ private fun ConfiguredMetaSections(
onPlayClick = onPrimaryPlayClick, onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
onSaveClick = onSaveClick, onSaveClick = onSaveClick,
onSaveLongClick = onSaveLongClick,
) )
} }
MetaScreenSectionKey.OVERVIEW -> { MetaScreenSectionKey.OVERVIEW -> {

View file

@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -44,6 +41,7 @@ fun DetailActionButtons(
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null, onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {}, onSaveClick: () -> Unit = {},
onSaveLongClick: (() -> Unit)? = null,
) { ) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay) val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus) val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
@ -96,11 +94,24 @@ fun DetailActionButtons(
} }
} }
OutlinedButton( Surface(
onClick = onSaveClick,
modifier = rowButtonModifier.height(50.dp), modifier = rowButtonModifier.height(50.dp),
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
contentColor = 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) { if (isSaved) {
Icon( Icon(
@ -127,4 +138,5 @@ fun DetailActionButtons(
) )
} }
} }
}
} }

View file

@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktLibraryRepository 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.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.postgrest
import io.github.jan.supabase.postgrest.rpc import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -65,12 +72,28 @@ object LibraryRepository {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) { if (authenticated) {
TraktLibraryRepository.preloadListTabsAsync() TraktLibraryRepository.preloadListTabsAsync()
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
runCatching { TraktLibraryRepository.refreshNow() } runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
} }
}
publish() 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 { syncScope.launch {
TraktLibraryRepository.uiState.collectLatest { TraktLibraryRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
@ -82,25 +105,31 @@ object LibraryRepository {
fun ensureLoaded() { fun ensureLoaded() {
TraktAuthRepository.ensureLoaded() TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktLibraryRepository.ensureLoaded() TraktLibraryRepository.ensureLoaded()
if (hasLoaded) return if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId) loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync() TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync() refreshTraktLibraryAsync()
} }
} }
}
fun onProfileChanged(profileId: Int) { fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return if (profileId == currentProfileId && hasLoaded) return
TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId) loadFromDisk(profileId)
TraktAuthRepository.onProfileChanged() TraktAuthRepository.onProfileChanged()
TraktLibraryRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync() TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync() refreshTraktLibraryAsync()
} }
} }
}
fun clearLocalState() { fun clearLocalState() {
hasLoaded = false hasLoaded = false
@ -130,7 +159,7 @@ object LibraryRepository {
suspend fun pullFromServer(profileId: Int) { suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId currentProfileId = profileId
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
runCatching { TraktLibraryRepository.refreshNow() } runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
publish() publish()
@ -157,7 +186,7 @@ object LibraryRepository {
fun toggleSaved(item: LibraryItem) { fun toggleSaved(item: LibraryItem) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
syncScope.launch { syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) } runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } } .onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
@ -175,7 +204,6 @@ object LibraryRepository {
fun save(item: LibraryItem) { fun save(item: LibraryItem) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) return
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
publish() publish()
persist() persist()
@ -184,7 +212,6 @@ object LibraryRepository {
fun remove(id: String) { fun remove(id: String) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) return
if (itemsById.remove(id) != null) { if (itemsById.remove(id) != null) {
publish() publish()
persist() persist()
@ -195,7 +222,7 @@ object LibraryRepository {
fun isSaved(id: String, type: String? = null): Boolean { fun isSaved(id: String, type: String? = null): Boolean {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
if (type != null) { if (type != null) {
return TraktLibraryRepository.isInAnyList(id, type) return TraktLibraryRepository.isInAnyList(id, type)
} }
@ -212,46 +239,65 @@ object LibraryRepository {
fun savedItem(id: String): LibraryItem? { fun savedItem(id: String): LibraryItem? {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
} }
return itemsById[id] return itemsById[id]
} }
fun traktListTabs() = TraktLibraryRepository.currentListTabs() fun libraryListTabs(): List<TraktListTab> {
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.currentListTabs()
} else {
emptyList()
}
return libraryTabsWithLocal(traktTabs)
}
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> { suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
}
val inLocal = itemsById.containsKey(item.id) 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<String, Boolean>) { suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
TraktLibraryRepository.applyMembershipChanges( val currentlyInLocal = itemsById.containsKey(item.id)
item = item, if (localDesired != currentlyInLocal) {
changes = TraktMembershipChanges(desiredMembership = desiredMembership), if (localDesired) {
)
publish()
return
}
val shouldBeSaved = desiredMembership.values.any { it }
if (shouldBeSaved) {
save(item) save(item)
} else { } else {
remove(item.id) remove(item.id)
} }
} }
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 {
publish()
}
}
private fun pushToServer() { private fun pushToServer() {
syncScope.launch { syncScope.launch {
if (TraktAuthRepository.isAuthenticated.value) return@launch
runCatching { runCatching {
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
val syncItems = itemsById.values.map { it.toSyncItem() } val syncItems = itemsById.values.map { it.toSyncItem() }
@ -267,7 +313,7 @@ object LibraryRepository {
} }
private fun publish() { private fun publish() {
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
val traktState = TraktLibraryRepository.uiState.value val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab -> val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty() val listItems = traktState.entriesByList[tab.key].orEmpty()
@ -334,9 +380,42 @@ object LibraryRepository {
publish() 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<TraktListTab>): List<TraktListTab> =
listOf(localLibraryListTab()) + traktTabs
internal fun libraryMembershipWithLocal(
inLocal: Boolean,
traktMembership: Map<String, Boolean> = emptyMap(),
): Map<String, Boolean> =
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
putAll(traktMembership)
}
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId, id = contentId,

View file

@ -136,8 +136,8 @@ object ProfileRepository {
) )
persist() persist()
WatchedRepository.onProfileChanged(profileIndex) WatchedRepository.onProfileChanged(profileIndex)
LibraryRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged() TraktSettingsRepository.onProfileChanged()
LibraryRepository.onProfileChanged(profileIndex)
WatchProgressRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex)
if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) {

View file

@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState 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_subtitle
import nuvio.composeapp.generated.resources.trakt_cw_window_title import nuvio.composeapp.generated.resources.trakt_cw_window_title
import nuvio.composeapp.generated.resources.trakt_days_format 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_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title 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_nuvio_selected
@ -136,15 +145,27 @@ private fun TraktFeatureRows(
commentsEnabled: Boolean, commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit, onCommentsEnabledChange: (Boolean) -> Unit,
) { ) {
var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) }
var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
var statusMessage by rememberSaveable { mutableStateOf<String?>(null) } var statusMessage by rememberSaveable { mutableStateOf<String?>(null) }
val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_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( TraktSettingsActionRow(
title = stringResource(Res.string.trakt_watch_progress_title), title = stringResource(Res.string.trakt_watch_progress_title),
description = stringResource(Res.string.trakt_watch_progress_subtitle), 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) { if (showWatchProgressDialog) {
WatchProgressSourceDialog( WatchProgressSourceDialog(
selectedSource = settingsUiState.watchProgressSource, selectedSource = settingsUiState.watchProgressSource,
onSourceSelected = { source -> onSourceSelected = { source ->
TraktSettingsRepository.setWatchProgressSource(source) TraktSettingsRepository.setWatchProgressSource(source)
statusMessage = if (source == WatchProgressSource.TRAKT) { statusMessage = if (source == WatchProgressSource.TRAKT) {
traktSelectedMessage traktProgressSelectedMessage
} else { } else {
nuvioSelectedMessage nuvioProgressSelectedMessage
} }
showWatchProgressDialog = false 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 @Composable
private fun watchProgressSourceLabel(source: WatchProgressSource): String = private fun watchProgressSourceLabel(source: WatchProgressSource): String =
when (source) { 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 @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun WatchProgressSourceDialog( private fun WatchProgressSourceDialog(

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.trakt package com.nuvio.app.features.trakt
import com.nuvio.app.features.library.LibrarySourceMode
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -35,16 +36,22 @@ enum class WatchProgressSource {
} }
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT 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( data class TraktSettingsUiState(
val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE,
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE,
) )
@Serializable @Serializable
private data class StoredTraktSettings( private data class StoredTraktSettings(
val watchProgressSource: String? = null, val watchProgressSource: String? = null,
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
val librarySourceMode: String? = null,
) )
object TraktSettingsRepository { object TraktSettingsRepository {
@ -87,6 +94,13 @@ object TraktSettingsRepository {
persist() persist()
} }
fun setLibrarySourceMode(mode: LibrarySourceMode) {
ensureLoaded()
if (_uiState.value.librarySourceMode == mode) return
_uiState.value = _uiState.value.copy(librarySourceMode = mode)
persist()
}
private fun loadFromDisk() { private fun loadFromDisk() {
hasLoaded = true hasLoaded = true
@ -104,6 +118,7 @@ object TraktSettingsRepository {
TraktSettingsUiState( TraktSettingsUiState(
watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource),
continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap),
librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode),
) )
} else { } else {
TraktSettingsUiState() TraktSettingsUiState()
@ -116,6 +131,7 @@ object TraktSettingsRepository {
StoredTraktSettings( StoredTraktSettings(
watchProgressSource = _uiState.value.watchProgressSource.name, watchProgressSource = _uiState.value.watchProgressSource.name,
continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap,
librarySourceMode = _uiState.value.librarySourceMode.name,
), ),
), ),
) )
@ -133,3 +149,18 @@ fun shouldUseTraktProgress(
isAuthenticated: Boolean, isAuthenticated: Boolean,
source: WatchProgressSource, source: WatchProgressSource,
): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT ): 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

View file

@ -1,6 +1,8 @@
package com.nuvio.app.features.library package com.nuvio.app.features.library
import com.nuvio.app.features.home.PosterShape 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -37,4 +39,34 @@ class LibraryRepositoryTest {
assertEquals(PosterShape.Poster, preview.posterShape) assertEquals(PosterShape.Poster, preview.posterShape)
assertEquals("banner", preview.banner) 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,
)
}
} }

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.trakt package com.nuvio.app.features.trakt
import com.nuvio.app.features.library.LibrarySourceMode
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@ -20,6 +21,19 @@ class TraktSettingsRepositoryTest {
assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) 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 @Test
fun `continue watching cap normalizes finite windows and all history`() { fun `continue watching cap normalizes finite windows and all history`() {
assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0))
@ -34,4 +48,20 @@ class TraktSettingsRepositoryTest {
assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC)) assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC))
assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT)) 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),
)
}
} }

View file

@ -45,6 +45,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_audience", "mdblist_use_audience",
"trakt_auth_payload", "trakt_auth_payload",
"trakt_library_payload", "trakt_library_payload",
"trakt_settings_payload",
) )
actual fun wipe() { actual fun wipe() {