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_trakt_auth",
"nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched",
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",

View file

@ -783,6 +783,14 @@
<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_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_subtitle">Choose which progress source powers resume and continue watching</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.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)

View file

@ -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 -> {

View file

@ -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,
)
}
}
}

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.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<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> {
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<String, Boolean>) {
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<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(
id = contentId,

View file

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

View file

@ -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<String?>(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(

View file

@ -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

View file

@ -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,
)
}
}

View file

@ -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),
)
}
}

View file

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