Merge branch 'traktsettings' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-06 13:56:28 +05:30
commit 9eb061892e
22 changed files with 1148 additions and 113 deletions

View file

@ -35,6 +35,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage
@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext)

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

@ -0,0 +1,26 @@
package com.nuvio.app.features.trakt
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object TraktSettingsStorage {
private const val preferencesName = "nuvio_trakt_settings"
private const val payloadKey = "trakt_settings_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(payloadKey), payload)
?.apply()
}
}

View file

@ -783,6 +783,28 @@
<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>
<string name="trakt_watch_progress_dialog_subtitle">Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active.</string>
<string name="trakt_watch_progress_source_trakt">Trakt</string>
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
<string name="trakt_continue_watching_window">Continue Watching Window</string>
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
<string name="trakt_cw_window_title">Continue Watching Window</string>
<string name="trakt_cw_window_subtitle">Choose how much Trakt activity should appear in continue watching.</string>
<string name="trakt_all_history">All history</string>
<string name="trakt_days_format">%1$d days</string>
<string name="source_audience_score">Audience Score</string>
<string name="source_imdb">IMDb</string>
<string name="source_letterboxd">Letterboxd</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

@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore
import com.nuvio.app.features.streams.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState()
CatalogRepository.clear()
StreamsRepository.clear()

View file

@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest
@ -156,6 +158,7 @@ object ProfileSettingsSync {
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" },
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
)
@ -199,6 +202,7 @@ object ProfileSettingsSync {
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
notificationsSettings = NotificationsSettingsPayload(
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
@ -230,6 +234,9 @@ object ProfileSettingsSync {
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged()
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
TraktSettingsRepository.onProfileChanged()
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
TraktCommentsSettings.onProfileChanged()
@ -244,6 +251,7 @@ object ProfileSettingsSync {
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded()
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
@ -263,6 +271,7 @@ object ProfileSettingsSync {
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}",
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
).joinToString(separator = "||")
@ -283,6 +292,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
)

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

@ -29,6 +29,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
@ -87,6 +91,10 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
@ -114,17 +122,31 @@ fun HomeScreen(
}
}
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
if (!isTraktAuthenticated) {
watchProgressUiState.entries
} else {
val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L)
watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
val isTraktProgressActive = remember(
isTraktAuthenticated,
traktSettingsUiState.watchProgressSource,
) {
shouldUseTraktProgress(
isAuthenticated = isTraktAuthenticated,
source = traktSettingsUiState.watchProgressSource,
)
}
val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) {
if (isTraktAuthenticated) emptyList() else watchedUiState.items
val effectiveWatchProgressEntries = remember(
watchProgressUiState.entries,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterEntriesForTraktContinueWatchingWindow(
entries = watchProgressUiState.entries,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
if (isTraktProgressActive) emptyList() else watchedUiState.items
}
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
@ -242,7 +264,7 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections)
}
LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
return@LaunchedEffect
@ -263,7 +285,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate,
showUnairedNextUp = isTraktAuthenticated,
showUnairedNextUp = isTraktProgressActive,
) ?: return@withPermit null
val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode)
@ -525,7 +547,21 @@ fun HomeScreen(
}
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<WatchProgressEntry> {
if (!isTraktProgressActive) return entries
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float,

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

@ -20,6 +20,7 @@ import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -135,6 +136,7 @@ object ProfileRepository {
)
persist()
WatchedRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged()
LibraryRepository.onProfileChanged(profileIndex)
WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex)

View file

@ -56,6 +56,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.tmdb.TmdbSettings
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -109,6 +111,10 @@ fun SettingsScreen(
TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled
}.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val addonsUiState by remember {
AddonRepository.initialize()
AddonRepository.uiState
@ -191,6 +197,7 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
@ -231,6 +238,7 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
@ -281,6 +289,7 @@ private fun MobileSettingsScreen(
mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
@ -409,6 +418,7 @@ private fun MobileSettingsScreen(
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
settingsUiState = traktSettingsUiState,
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)
@ -446,6 +456,7 @@ private fun TabletSettingsScreen(
mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
@ -645,6 +656,7 @@ private fun TabletSettingsScreen(
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = true,
uiState = traktAuthUiState,
settingsUiState = traktSettingsUiState,
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)

View file

@ -1,31 +1,57 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.TraktSettingsUiState
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.traktBrandPainter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_playback_dialog_close
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
import nuvio.composeapp.generated.resources.settings_trakt_authentication
import nuvio.composeapp.generated.resources.settings_trakt_comments
@ -42,11 +68,34 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
import nuvio.composeapp.generated.resources.settings_trakt_open_login
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
import nuvio.composeapp.generated.resources.trakt_all_history
import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle
import nuvio.composeapp.generated.resources.trakt_continue_watching_window
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
import nuvio.composeapp.generated.resources.trakt_cw_window_title
import nuvio.composeapp.generated.resources.trakt_days_format
import nuvio.composeapp.generated.resources.trakt_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
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt
import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle
import nuvio.composeapp.generated.resources.trakt_watch_progress_title
import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.traktSettingsContent(
isTablet: Boolean,
uiState: TraktAuthUiState,
settingsUiState: TraktSettingsUiState,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
@ -77,12 +126,414 @@ internal fun LazyListScope.traktSettingsContent(
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_trakt_comments),
description = stringResource(Res.string.settings_trakt_comments_description),
checked = commentsEnabled,
TraktFeatureRows(
isTablet = isTablet,
onCheckedChange = onCommentsEnabledChange,
settingsUiState = settingsUiState,
commentsEnabled = commentsEnabled,
onCommentsEnabledChange = onCommentsEnabledChange,
)
}
}
}
}
}
@Composable
private fun TraktFeatureRows(
isTablet: Boolean,
settingsUiState: TraktSettingsUiState,
commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit,
) {
var 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 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),
value = watchProgressValue,
isTablet = isTablet,
onClick = { showWatchProgressDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
TraktSettingsActionRow(
title = stringResource(Res.string.trakt_continue_watching_window),
description = stringResource(Res.string.trakt_continue_watching_subtitle),
value = continueWatchingWindowValue,
isTablet = isTablet,
onClick = { showContinueWatchingWindowDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_trakt_comments),
description = stringResource(Res.string.settings_trakt_comments_description),
checked = commentsEnabled,
isTablet = isTablet,
onCheckedChange = onCommentsEnabledChange,
)
statusMessage?.takeIf { it.isNotBlank() }?.let { message ->
SettingsGroupDivider(isTablet = isTablet)
TraktInfoRow(
isTablet = isTablet,
text = message,
)
}
if (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) {
traktProgressSelectedMessage
} else {
nuvioProgressSelectedMessage
}
showWatchProgressDialog = false
},
onDismiss = { showWatchProgressDialog = false },
)
}
if (showContinueWatchingWindowDialog) {
ContinueWatchingWindowDialog(
selectedDaysCap = settingsUiState.continueWatchingDaysCap,
onDaysCapSelected = { days ->
TraktSettingsRepository.setContinueWatchingDaysCap(days)
showContinueWatchingWindowDialog = false
},
onDismiss = { showContinueWatchingWindowDialog = false },
)
}
}
@Composable
private fun TraktSettingsActionRow(
title: String,
description: String,
value: String,
isTablet: Boolean,
onClick: () -> Unit,
) {
val verticalPadding = if (isTablet) 16.dp else 14.dp
val horizontalPadding = if (isTablet) 20.dp else 16.dp
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp)
.widthIn(max = if (isTablet) 560.dp else Dp.Unspecified),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun TraktInfoRow(
isTablet: Boolean,
text: String,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 14.dp else 12.dp
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun 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) {
WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt)
WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio)
}
@Composable
private fun continueWatchingDaysCapLabel(daysCap: Int): String {
val normalized = normalizeTraktContinueWatchingDaysCap(daysCap)
return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
stringResource(Res.string.trakt_all_history)
} else {
stringResource(Res.string.trakt_days_format, normalized)
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun 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(
selectedSource: WatchProgressSource,
onSourceSelected: (WatchProgressSource) -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.trakt_watch_progress_dialog_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source ->
TraktDialogOption(
label = watchProgressSourceLabel(source),
selected = source == selectedSource,
onClick = { onSourceSelected(source) },
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(Res.string.settings_playback_dialog_close),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ContinueWatchingWindowDialog(
selectedDaysCap: Int,
onDaysCapSelected: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap)
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.trakt_cw_window_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(Res.string.trakt_cw_window_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TraktContinueWatchingDaysOptions.forEach { days ->
val normalizedDays = normalizeTraktContinueWatchingDaysCap(days)
TraktDialogOption(
label = continueWatchingDaysCapLabel(days),
selected = normalizedDays == normalizedSelected,
onClick = { onDaysCapSelected(days) },
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(Res.string.settings_playback_dialog_close),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun TraktDialogOption(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
val containerColor = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}

View file

@ -0,0 +1,166 @@
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
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0
const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60
const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7
const val TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP = 365
val TraktContinueWatchingDaysOptions: List<Int> = listOf(
14,
30,
TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
90,
180,
TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP,
TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
)
@Serializable
enum class WatchProgressSource {
TRAKT,
NUVIO_SYNC;
companion object {
fun fromStorage(value: String?): WatchProgressSource =
entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE
}
}
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT
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 {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _uiState = MutableStateFlow(TraktSettingsUiState())
val uiState: StateFlow<TraktSettingsUiState> = _uiState.asStateFlow()
private var hasLoaded = false
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
}
fun clearLocalState() {
hasLoaded = false
_uiState.value = TraktSettingsUiState()
}
fun setWatchProgressSource(source: WatchProgressSource) {
ensureLoaded()
if (_uiState.value.watchProgressSource == source) return
_uiState.value = _uiState.value.copy(watchProgressSource = source)
persist()
}
fun setContinueWatchingDaysCap(days: Int) {
ensureLoaded()
val normalized = normalizeTraktContinueWatchingDaysCap(days)
if (_uiState.value.continueWatchingDaysCap == normalized) return
_uiState.value = _uiState.value.copy(continueWatchingDaysCap = normalized)
persist()
}
fun setLibrarySourceMode(mode: LibrarySourceMode) {
ensureLoaded()
if (_uiState.value.librarySourceMode == mode) return
_uiState.value = _uiState.value.copy(librarySourceMode = mode)
persist()
}
private fun loadFromDisk() {
hasLoaded = true
val payload = TraktSettingsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = TraktSettingsUiState()
return
}
val stored = runCatching {
json.decodeFromString<StoredTraktSettings>(payload)
}.getOrNull()
_uiState.value = if (stored != null) {
TraktSettingsUiState(
watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource),
continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap),
librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode),
)
} else {
TraktSettingsUiState()
}
}
private fun persist() {
TraktSettingsStorage.savePayload(
json.encodeToString(
StoredTraktSettings(
watchProgressSource = _uiState.value.watchProgressSource.name,
continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap,
librarySourceMode = _uiState.value.librarySourceMode.name,
),
),
)
}
}
fun normalizeTraktContinueWatchingDaysCap(days: Int): Int =
if (days == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
} else {
days.coerceIn(TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP, TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP)
}
fun shouldUseTraktProgress(
isAuthenticated: Boolean,
source: WatchProgressSource,
): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT
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

@ -0,0 +1,6 @@
package com.nuvio.app.features.trakt
internal expect object TraktSettingsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -7,6 +7,8 @@ import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktProgressRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
@ -37,7 +39,11 @@ object WatchProgressRepository {
init {
syncScope.launch {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) {
if (shouldUseTraktProgressSource(
isAuthenticated = authenticated,
source = TraktSettingsRepository.uiState.value.watchProgressSource,
)
) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } }
}
@ -45,9 +51,23 @@ object WatchProgressRepository {
}
}
syncScope.launch {
TraktSettingsRepository.uiState.collectLatest { settings ->
if (shouldUseTraktProgressSource(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = settings.watchProgressSource,
)
) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } }
}
publish()
}
}
syncScope.launch {
TraktProgressRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) {
if (shouldUseTraktProgress()) {
publish()
}
}
@ -56,19 +76,21 @@ object WatchProgressRepository {
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) {
if (shouldUseTraktProgress()) {
TraktProgressRepository.refreshAsync()
}
}
fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return
TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId)
TraktProgressRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) {
if (shouldUseTraktProgress()) {
TraktProgressRepository.refreshAsync()
}
}
@ -79,6 +101,7 @@ object WatchProgressRepository {
currentProfileId = 1
entriesByVideoId.clear()
TraktProgressRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
_uiState.value = WatchProgressUiState()
}
@ -98,9 +121,12 @@ object WatchProgressRepository {
}
suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
currentProfileId = profileId
if (TraktAuthRepository.isAuthenticated.value) {
if (shouldUseTraktProgress()) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish()
@ -368,7 +394,6 @@ object WatchProgressRepository {
}
private fun pushScrobbleToServer(entry: WatchProgressEntry) {
if (shouldUseTraktProgress()) return
syncScope.launch {
runCatching {
val profileId = ProfileRepository.activeProfileId
@ -406,7 +431,11 @@ object WatchProgressRepository {
)
}
private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value
private fun shouldUseTraktProgress(): Boolean =
shouldUseTraktProgressSource(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = TraktSettingsRepository.uiState.value.watchProgressSource,
)
private fun currentEntries(): List<WatchProgressEntry> {
return if (shouldUseTraktProgress()) {

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import kotlin.test.Test
import kotlin.test.assertEquals
@ -60,6 +61,68 @@ class HomeScreenTest {
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
}
@Test
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
val oldEntry = progressEntry(
videoId = "old",
title = "Old",
lastUpdatedEpochMs = 1_000L,
seasonNumber = null,
episodeNumber = null,
)
val recentEntry = progressEntry(
videoId = "recent",
title = "Recent",
lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
seasonNumber = null,
episodeNumber = null,
)
val entries = listOf(oldEntry, recentEntry)
val filtered = filterEntriesForTraktContinueWatchingWindow(
entries = entries,
isTraktProgressActive = true,
daysCap = 60,
nowEpochMs = 90L * MILLIS_PER_DAY,
)
val nuvioSource = filterEntriesForTraktContinueWatchingWindow(
entries = entries,
isTraktProgressActive = false,
daysCap = 60,
nowEpochMs = 90L * MILLIS_PER_DAY,
)
assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId))
assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId))
}
@Test
fun `Trakt all history window keeps old progress`() {
val oldEntry = progressEntry(
videoId = "old",
title = "Old",
lastUpdatedEpochMs = 1_000L,
seasonNumber = null,
episodeNumber = null,
)
val recentEntry = progressEntry(
videoId = "recent",
title = "Recent",
lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
seasonNumber = null,
episodeNumber = null,
)
val result = filterEntriesForTraktContinueWatchingWindow(
entries = listOf(oldEntry, recentEntry),
isTraktProgressActive = true,
daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
nowEpochMs = 90L * MILLIS_PER_DAY,
)
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
}
private fun progressEntry(
videoId: String,
title: String,
@ -100,4 +163,8 @@ class HomeScreenTest {
durationMs = 0L,
progressFraction = 0f,
)
}
private companion object {
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
}
}

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

@ -0,0 +1,67 @@
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
import kotlin.test.assertTrue
class TraktSettingsRepositoryTest {
@Test
fun `watch progress source defaults to Trakt for unset or invalid storage`() {
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null))
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(""))
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("not-a-source"))
}
@Test
fun `watch progress source restores valid storage values`() {
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT"))
assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC"))
}
@Test
fun `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))
assertEquals(7, normalizeTraktContinueWatchingDaysCap(1))
assertEquals(60, normalizeTraktContinueWatchingDaysCap(60))
assertEquals(365, normalizeTraktContinueWatchingDaysCap(999))
}
@Test
fun `Trakt progress is active only when authenticated and selected`() {
assertFalse(shouldUseTraktProgress(isAuthenticated = false, source = WatchProgressSource.TRAKT))
assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC))
assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT))
}
@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() {

View file

@ -0,0 +1,15 @@
package com.nuvio.app.features.trakt
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
internal actual object TraktSettingsStorage {
private const val payloadKey = "trakt_settings_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey))
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey))
}
}