mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-04 10:36:56 +00:00
Merge branch 'traktsettings' into cmp-rewrite
This commit is contained in:
commit
9eb061892e
22 changed files with 1148 additions and 113 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
internal expect object TraktSettingsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"mdblist_use_audience",
|
||||
"trakt_auth_payload",
|
||||
"trakt_library_payload",
|
||||
"trakt_settings_payload",
|
||||
)
|
||||
|
||||
actual fun wipe() {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue