From a36e927c0b9a1f062d8ff3cc09415c4dc6c6d915 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 5 May 2026 13:51:16 +0530 Subject: [PATCH 01/18] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 2c747aca..0a0e7524 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=50 -MARKETING_VERSION=0.1.13 +CURRENT_PROJECT_VERSION=53 +MARKETING_VERSION=0.1.14 From 06553b9b26fd9c46569d11dfca37bd1550d8e631 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 5 May 2026 20:43:40 +0530 Subject: [PATCH 02/18] ref: add submit intro button to top player controls --- .../app/features/player/PlayerControls.kt | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 48ffd528..13f975ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -131,6 +131,7 @@ internal fun PlayerControlsShell( episodeTitle = episodeTitle, metrics = metrics, isLocked = isLocked, + onSubmitIntroClick = onSubmitIntroClick, onLockToggle = onLockToggle, onBack = onBack, modifier = Modifier @@ -168,7 +169,6 @@ internal fun PlayerControlsShell( onAudioClick = onAudioClick, onSourcesClick = onSourcesClick, onEpisodesClick = onEpisodesClick, - onSubmitIntroClick = onSubmitIntroClick, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() @@ -189,6 +189,7 @@ private fun PlayerHeader( episodeTitle: String?, metrics: PlayerLayoutMetrics, isLocked: Boolean, + onSubmitIntroClick: (() -> Unit)?, onLockToggle: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -264,6 +265,15 @@ private fun PlayerHeader( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (onSubmitIntroClick != null) { + PlayerHeaderIconButton( + icon = Icons.Rounded.Flag, + contentDescription = "Submit Intro", + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onSubmitIntroClick, + ) + } PlayerHeaderIconButton( icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, contentDescription = if (isLocked) { @@ -424,7 +434,6 @@ private fun ProgressControls( onAudioClick: () -> Unit, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, - onSubmitIntroClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) @@ -506,13 +515,6 @@ private fun ProgressControls( onClick = onEpisodesClick, ) } - if (onSubmitIntroClick != null) { - PlayerActionPillButton( - label = "Submit Intro", - icon = Icons.Rounded.Flag, - onClick = onSubmitIntroClick, - ) - } } } } @@ -676,6 +678,9 @@ private fun PlayerActionPillButton( text = label, style = MaterialTheme.nuvioTypeScale.labelSm, color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, ) } } From d00aba86afe74455661745f7bf0d8403ef03f48f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 13:28:43 +0530 Subject: [PATCH 03/18] feat: adding trakt watchprogress option to choose between trakt/nuvio as preferred --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../trakt/TraktSettingsStorage.android.kt | 26 ++ .../composeResources/values/strings.xml | 14 + .../core/storage/LocalAccountDataCleaner.kt | 2 + .../app/core/sync/ProfileSettingsSync.kt | 10 + .../com/nuvio/app/features/home/HomeScreen.kt | 60 ++- .../features/profiles/ProfileRepository.kt | 2 + .../app/features/settings/SettingsScreen.kt | 12 + .../features/settings/TraktSettingsPage.kt | 364 +++++++++++++++++- .../features/trakt/TraktSettingsRepository.kt | 135 +++++++ .../features/trakt/TraktSettingsStorage.kt | 6 + .../watchprogress/WatchProgressRepository.kt | 43 ++- .../nuvio/app/features/home/HomeScreenTest.kt | 69 +++- .../trakt/TraktSettingsRepositoryTest.kt | 37 ++ .../trakt/TraktSettingsStorage.ios.kt | 15 + 15 files changed, 772 insertions(+), 25 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index c4124d0d..e899b044 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt new file mode 100644 index 00000000..35f23eb7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 43f04cb0..04177a28 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -783,6 +783,20 @@ Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. Sign in with Trakt to enable list-based saving and Trakt library mode. + Watch Progress + Choose which progress source powers resume and continue watching + Watch Progress + Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active. + Trakt + Nuvio Sync + Watch progress source set to Trakt + Watch progress source set to Nuvio Sync + Continue Watching Window + Trakt history considered for continue watching + Continue Watching Window + Choose how much Trakt activity should appear in continue watching. + All history + %1$d days Audience Score IMDb Letterboxd diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 96e2a31e..603fce83 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index a56aefb8..cdbd477a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -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(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 82659478..644295bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 07a9d9c6..1fec7c1e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -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 @@ -136,6 +137,7 @@ object ProfileRepository { persist() WatchedRepository.onProfileChanged(profileIndex) LibraryRepository.onProfileChanged(profileIndex) + TraktSettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 6c80adb8..2ed86c15 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, 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, metaScreenSettingsUiState: MetaScreenSettingsUiState, @@ -645,6 +656,7 @@ private fun TabletSettingsScreen( SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, + settingsUiState = traktSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 82130875..76f3aa35 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -1,31 +1,56 @@ 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.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 +67,26 @@ 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_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 +117,326 @@ 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 showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } + var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } + var statusMessage by rememberSaveable { mutableStateOf(null) } + + val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) + val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) + val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) + val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) + + 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 (showWatchProgressDialog) { + WatchProgressSourceDialog( + selectedSource = settingsUiState.watchProgressSource, + onSourceSelected = { source -> + TraktSettingsRepository.setWatchProgressSource(source) + statusMessage = if (source == WatchProgressSource.TRAKT) { + traktSelectedMessage + } else { + nuvioSelectedMessage + } + 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 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 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt new file mode 100644 index 00000000..3f6a66c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -0,0 +1,135 @@ +package com.nuvio.app.features.trakt + +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 = 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 + +data class TraktSettingsUiState( + val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, +) + +@Serializable +private data class StoredTraktSettings( + val watchProgressSource: String? = null, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, +) + +object TraktSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(TraktSettingsUiState()) + val uiState: StateFlow = _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() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = TraktSettingsStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = TraktSettingsUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = if (stored != null) { + TraktSettingsUiState( + watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), + continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), + ) + } else { + TraktSettingsUiState() + } + } + + private fun persist() { + TraktSettingsStorage.savePayload( + json.encodeToString( + StoredTraktSettings( + watchProgressSource = _uiState.value.watchProgressSource.name, + continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, + ), + ), + ) + } +} + +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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt new file mode 100644 index 00000000..f1302794 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.trakt + +internal expect object TraktSettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 55adcebe..d46c40c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -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 { return if (shouldUseTraktProgress()) { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 849211a7..51da33ff 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -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, ) -} \ No newline at end of file + + private companion object { + const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt new file mode 100644 index 00000000..32928ef7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.trakt + +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 `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)) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt new file mode 100644 index 00000000..06c60535 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt @@ -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)) + } +} From 55b97d97adfe0d978874295fc880af4163a0fce2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 13:52:49 +0530 Subject: [PATCH 04/18] feat: trakt library source option to switch between trakt/nuvio library --- ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../composeResources/values/strings.xml | 8 + .../commonMain/kotlin/com/nuvio/app/App.kt | 14 +- .../app/features/details/MetaDetailsScreen.kt | 49 ++++--- .../details/components/DetailActionButtons.kt | 64 ++++---- .../app/features/library/LibraryRepository.kt | 137 ++++++++++++++---- .../features/profiles/ProfileRepository.kt | 2 +- .../features/settings/TraktSettingsPage.kt | 105 +++++++++++++- .../features/trakt/TraktSettingsRepository.kt | 31 ++++ .../features/library/LibraryRepositoryTest.kt | 32 ++++ .../trakt/TraktSettingsRepositoryTest.kt | 30 ++++ .../PlatformLocalAccountDataCleaner.ios.kt | 1 + 12 files changed, 381 insertions(+), 93 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 7f970d32..de84c4a5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -16,6 +16,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_mdblist_settings", "nuvio_trakt_auth", "nuvio_trakt_library", + "nuvio_trakt_settings", "nuvio_watched", "nuvio_stream_link_cache", "nuvio_continue_watching_preferences", diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 04177a28..5c657824 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -783,6 +783,14 @@ Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. Sign in with Trakt to enable list-based saving and Trakt library mode. + Library Source + Choose which library to use for saving and viewing your collection + Library Source + Choose where to save and manage your library items + Trakt + Nuvio Library + Trakt library selected + Nuvio library selected Watch Progress Choose which progress source powers resume and continue watching Watch Progress diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f9e85f6c..eea60cd6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -152,8 +152,6 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -486,10 +484,6 @@ private fun MainAppContent( LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() - val traktAuthUiState by remember { - TraktAuthRepository.ensureLoaded() - TraktAuthRepository.uiState - }.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val playerSettingsUiState by remember { @@ -508,7 +502,7 @@ private fun MainAppContent( NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) - val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED + val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } @@ -1664,12 +1658,12 @@ private fun MainAppContent( onToggleLibrary = { selectedPosterForActions?.let { preview -> val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) - if (!isTraktConnected) { + if (!isTraktLibrarySource) { LibraryRepository.toggleSaved(libraryItem) } else { pickerItem = libraryItem pickerTitle = preview.name - pickerTabs = LibraryRepository.traktListTabs() + pickerTabs = LibraryRepository.libraryListTabs() pickerMembership = pickerTabs.associate { it.key to false } pickerPending = true pickerError = null @@ -1677,7 +1671,7 @@ private fun MainAppContent( coroutineScope.launch { runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.traktListTabs() + val tabs = LibraryRepository.libraryListTabs() pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 0161bba5..b4f31fe6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -276,39 +276,39 @@ fun MetaDetailsScreen( val isSaved = remember( libraryUiState.items, libraryUiState.sections, - traktAuthUiState.mode, + libraryUiState.sourceMode, meta.id, meta.type, ) { LibraryRepository.isSaved(meta.id, meta.type) } - val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED - val toggleSaved = remember(meta, isTraktConnected) { + val openLibraryListPicker = remember(meta) { { val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L) - if (!isTraktConnected) { - LibraryRepository.toggleSaved(libraryItem) - } else { - pickerTabs = LibraryRepository.traktListTabs() - pickerMembership = pickerTabs.associate { it.key to false } - pickerPending = true - pickerError = null - showLibraryListPicker = true - detailsScope.launch { - runCatching { - val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.traktListTabs() - pickerTabs = tabs - pickerMembership = tabs.associate { tab -> - tab.key to (snapshot[tab.key] == true) - } - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) + pickerTabs = LibraryRepository.libraryListTabs() + pickerMembership = pickerTabs.associate { it.key to false } + pickerPending = true + pickerError = null + showLibraryListPicker = true + detailsScope.launch { + runCatching { + val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) + val tabs = LibraryRepository.libraryListTabs() + pickerTabs = tabs + pickerMembership = tabs.associate { tab -> + tab.key to (snapshot[tab.key] == true) } - pickerPending = false + }.onFailure { error -> + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } - Unit + pickerPending = false } + Unit + } + } + val toggleSaved = remember(meta) { + { + LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L)) } } val movieProgress = watchProgressUiState.byVideoId[meta.id] @@ -639,6 +639,7 @@ fun MetaDetailsScreen( onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, showManualPlayOption = showManualPlayOption, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeNumber = seriesAction?.episodeNumber, @@ -946,6 +947,7 @@ private fun ConfiguredMetaSections( onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, showManualPlayOption: Boolean, preferredEpisodeSeasonNumber: Int?, preferredEpisodeNumber: Int?, @@ -1010,6 +1012,7 @@ private fun ConfiguredMetaSections( onPlayClick = onPrimaryPlayClick, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, ) } MetaScreenSectionKey.OVERVIEW -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 6eb1d515..d5be0d59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,6 +41,7 @@ fun DetailActionButtons( onPlayClick: () -> Unit = {}, onPlayLongClick: (() -> Unit)? = null, onSaveClick: () -> Unit = {}, + onSaveLongClick: (() -> Unit)? = null, ) { val playPainter = appIconPainter(AppIconResource.PlayerPlay) val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus) @@ -96,35 +94,49 @@ fun DetailActionButtons( } } - OutlinedButton( - onClick = onSaveClick, + Surface( modifier = rowButtonModifier.height(50.dp), shape = RoundedCornerShape(40.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0f), + contentColor = MaterialTheme.colorScheme.onSurface, ) { - if (isSaved) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } else { - Icon( - painter = libraryAddPainter, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurface, + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onSaveClick, + onLongClick = onSaveLongClick, + role = Role.Button, + ) + .height(50.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSaved) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } else { + Icon( + painter = libraryAddPainter, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = saveLabel, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = saveLabel, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index a3983cbf..c93d5caa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktLibraryRepository +import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktListType import com.nuvio.app.features.trakt.TraktMembershipChanges +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode +import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -65,12 +72,28 @@ object LibraryRepository { TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> if (authenticated) { TraktLibraryRepository.preloadListTabsAsync() - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } + if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) { + runCatching { TraktLibraryRepository.refreshNow() } + .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } + } } publish() } } + syncScope.launch { + TraktSettingsRepository.uiState + .map { it.librarySourceMode } + .distinctUntilChanged() + .collectLatest { source -> + if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) { + TraktLibraryRepository.preloadListTabsAsync() + publish() + refreshTraktLibraryAsync() + } else { + publish() + } + } + } syncScope.launch { TraktLibraryRepository.uiState.collectLatest { if (TraktAuthRepository.isAuthenticated.value) { @@ -82,23 +105,29 @@ object LibraryRepository { fun ensureLoaded() { TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktLibraryRepository.ensureLoaded() if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() - refreshTraktLibraryAsync() + if (isTraktLibrarySourceActive()) { + refreshTraktLibraryAsync() + } } } fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return + TraktSettingsRepository.onProfileChanged() loadFromDisk(profileId) TraktAuthRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged() if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() - refreshTraktLibraryAsync() + if (isTraktLibrarySourceActive()) { + refreshTraktLibraryAsync() + } } } @@ -130,7 +159,7 @@ object LibraryRepository { suspend fun pullFromServer(profileId: Int) { currentProfileId = profileId - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { runCatching { TraktLibraryRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } publish() @@ -157,7 +186,7 @@ object LibraryRepository { fun toggleSaved(item: LibraryItem) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { syncScope.launch { runCatching { TraktLibraryRepository.toggleWatchlist(item) } .onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } } @@ -175,7 +204,6 @@ object LibraryRepository { fun save(item: LibraryItem) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) return itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) publish() persist() @@ -184,7 +212,6 @@ object LibraryRepository { fun remove(id: String) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) return if (itemsById.remove(id) != null) { publish() persist() @@ -195,7 +222,7 @@ object LibraryRepository { fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { if (type != null) { return TraktLibraryRepository.isInAnyList(id, type) } @@ -212,46 +239,65 @@ object LibraryRepository { fun savedItem(id: String): LibraryItem? { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } } return itemsById[id] } - fun traktListTabs() = TraktLibraryRepository.currentListTabs() + fun libraryListTabs(): List { + val traktTabs = if (TraktAuthRepository.isAuthenticated.value) { + TraktLibraryRepository.currentListTabs() + } else { + emptyList() + } + return libraryTabsWithLocal(traktTabs) + } + + fun traktListTabs(): List = libraryListTabs() suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { - return TraktLibraryRepository.getMembershipSnapshot(item).listMembership - } val inLocal = itemsById.containsKey(item.id) - return mapOf(LOCAL_LIST_KEY to inLocal) + if (TraktAuthRepository.isAuthenticated.value) { + val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership + return libraryMembershipWithLocal( + inLocal = inLocal, + traktMembership = traktMembership, + ) + } + return libraryMembershipWithLocal(inLocal = inLocal) } suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.applyMembershipChanges( - item = item, - changes = TraktMembershipChanges(desiredMembership = desiredMembership), - ) - publish() - return + val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true + val currentlyInLocal = itemsById.containsKey(item.id) + if (localDesired != currentlyInLocal) { + if (localDesired) { + save(item) + } else { + remove(item.id) + } } - val shouldBeSaved = desiredMembership.values.any { it } - if (shouldBeSaved) { - save(item) + if (TraktAuthRepository.isAuthenticated.value) { + val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY } + if (traktMembership.isNotEmpty()) { + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges(desiredMembership = traktMembership), + ) + } + publish() } else { - remove(item.id) + publish() } } private fun pushToServer() { syncScope.launch { - if (TraktAuthRepository.isAuthenticated.value) return@launch runCatching { val profileId = ProfileRepository.activeProfileId val syncItems = itemsById.values.map { it.toSyncItem() } @@ -267,7 +313,7 @@ object LibraryRepository { } private fun publish() { - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { val traktState = TraktLibraryRepository.uiState.value val sections = traktState.listTabs.mapNotNull { tab -> val listItems = traktState.entriesByList[tab.key].orEmpty() @@ -334,9 +380,42 @@ object LibraryRepository { publish() } } + + private fun selectedLibrarySourceMode(): LibrarySourceMode { + TraktSettingsRepository.ensureLoaded() + return TraktSettingsRepository.uiState.value.librarySourceMode + } + + private fun effectiveLibrarySourceMode(): LibrarySourceMode = + resolveEffectiveLibrarySourceMode( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = selectedLibrarySourceMode(), + ) + + private fun isTraktLibrarySourceActive(): Boolean = + effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT } -private const val LOCAL_LIST_KEY = "local" +internal const val LOCAL_LIBRARY_LIST_KEY = "local" +internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library" + +internal fun localLibraryListTab(): TraktListTab = + TraktListTab( + key = LOCAL_LIBRARY_LIST_KEY, + title = LOCAL_LIBRARY_LIST_TITLE, + type = TraktListType.WATCHLIST, + ) + +internal fun libraryTabsWithLocal(traktTabs: List): List = + listOf(localLibraryListTab()) + traktTabs + +internal fun libraryMembershipWithLocal( + inLocal: Boolean, + traktMembership: Map = emptyMap(), +): Map = + linkedMapOf(LOCAL_LIBRARY_LIST_KEY to inLocal).apply { + putAll(traktMembership) + } private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( id = contentId, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 1fec7c1e..01904938 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -136,8 +136,8 @@ object ProfileRepository { ) persist() WatchedRepository.onProfileChanged(profileIndex) - LibraryRepository.onProfileChanged(profileIndex) TraktSettingsRepository.onProfileChanged() + LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 76f3aa35..198b3123 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState @@ -73,6 +74,14 @@ import nuvio.composeapp.generated.resources.trakt_continue_watching_window import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle import nuvio.composeapp.generated.resources.trakt_cw_window_title import nuvio.composeapp.generated.resources.trakt_days_format +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected +import nuvio.composeapp.generated.resources.trakt_library_source_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_title +import nuvio.composeapp.generated.resources.trakt_library_source_trakt +import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected @@ -136,15 +145,27 @@ private fun TraktFeatureRows( commentsEnabled: Boolean, onCommentsEnabledChange: (Boolean) -> Unit, ) { + var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } var statusMessage by rememberSaveable { mutableStateOf(null) } + val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) - val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) - val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) + val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) + val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) + val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) + val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_library_source_title), + description = stringResource(Res.string.trakt_library_source_subtitle), + value = librarySourceValue, + isTablet = isTablet, + onClick = { showLibrarySourceDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) TraktSettingsActionRow( title = stringResource(Res.string.trakt_watch_progress_title), description = stringResource(Res.string.trakt_watch_progress_subtitle), @@ -176,15 +197,31 @@ private fun TraktFeatureRows( ) } + if (showLibrarySourceDialog) { + LibrarySourceModeDialog( + selectedSource = settingsUiState.librarySourceMode, + onSourceSelected = { source -> + TraktSettingsRepository.setLibrarySourceMode(source) + statusMessage = if (source == LibrarySourceMode.TRAKT) { + traktLibrarySelectedMessage + } else { + nuvioLibrarySelectedMessage + } + showLibrarySourceDialog = false + }, + onDismiss = { showLibrarySourceDialog = false }, + ) + } + if (showWatchProgressDialog) { WatchProgressSourceDialog( selectedSource = settingsUiState.watchProgressSource, onSourceSelected = { source -> TraktSettingsRepository.setWatchProgressSource(source) statusMessage = if (source == WatchProgressSource.TRAKT) { - traktSelectedMessage + traktProgressSelectedMessage } else { - nuvioSelectedMessage + nuvioProgressSelectedMessage } showWatchProgressDialog = false }, @@ -271,6 +308,13 @@ private fun TraktInfoRow( ) } +@Composable +private fun librarySourceModeLabel(source: LibrarySourceMode): String = + when (source) { + LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) + LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + } + @Composable private fun watchProgressSourceLabel(source: WatchProgressSource): String = when (source) { @@ -288,6 +332,59 @@ private fun continueWatchingDaysCapLabel(daysCap: Int): String { } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LibrarySourceModeDialog( + selectedSource: LibrarySourceMode, + onSourceSelected: (LibrarySourceMode) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_library_source_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_library_source_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source -> + TraktDialogOption( + label = librarySourceModeLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun WatchProgressSourceDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index 3f6a66c4..ee9cccd4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.trakt +import com.nuvio.app.features.library.LibrarySourceMode import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,16 +36,22 @@ enum class WatchProgressSource { } val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE data class TraktSettingsUiState( val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE, ) @Serializable private data class StoredTraktSettings( val watchProgressSource: String? = null, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + val librarySourceMode: String? = null, ) object TraktSettingsRepository { @@ -87,6 +94,13 @@ object TraktSettingsRepository { persist() } + fun setLibrarySourceMode(mode: LibrarySourceMode) { + ensureLoaded() + if (_uiState.value.librarySourceMode == mode) return + _uiState.value = _uiState.value.copy(librarySourceMode = mode) + persist() + } + private fun loadFromDisk() { hasLoaded = true @@ -104,6 +118,7 @@ object TraktSettingsRepository { TraktSettingsUiState( watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), + librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode), ) } else { TraktSettingsUiState() @@ -116,6 +131,7 @@ object TraktSettingsRepository { StoredTraktSettings( watchProgressSource = _uiState.value.watchProgressSource.name, continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, + librarySourceMode = _uiState.value.librarySourceMode.name, ), ), ) @@ -133,3 +149,18 @@ fun shouldUseTraktProgress( isAuthenticated: Boolean, source: WatchProgressSource, ): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT + +fun effectiveLibrarySourceMode( + isAuthenticated: Boolean, + source: LibrarySourceMode, +): LibrarySourceMode = + if (isAuthenticated && source == LibrarySourceMode.TRAKT) { + LibrarySourceMode.TRAKT + } else { + LibrarySourceMode.LOCAL + } + +fun shouldUseTraktLibrary( + isAuthenticated: Boolean, + source: LibrarySourceMode, +): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index b33fe936..f0ac0f9f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.library import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktListType import kotlin.test.Test import kotlin.test.assertEquals @@ -37,4 +39,34 @@ class LibraryRepositoryTest { assertEquals(PosterShape.Poster, preview.posterShape) assertEquals("banner", preview.banner) } + + @Test + fun `library tabs include local Nuvio library before Trakt tabs`() { + val traktTab = TraktListTab( + key = "trakt:watchlist", + title = "Watchlist", + type = TraktListType.WATCHLIST, + ) + + val tabs = libraryTabsWithLocal(listOf(traktTab)) + + assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key }) + assertEquals("Nuvio Library", tabs.first().title) + } + + @Test + fun `library membership always includes local state before Trakt membership`() { + val membership = libraryMembershipWithLocal( + inLocal = true, + traktMembership = mapOf("trakt:watchlist" to false), + ) + + assertEquals( + mapOf( + "local" to true, + "trakt:watchlist" to false, + ), + membership, + ) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt index 32928ef7..f504fcc8 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.trakt +import com.nuvio.app.features.library.LibrarySourceMode import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -20,6 +21,19 @@ class TraktSettingsRepositoryTest { assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) } + @Test + fun `library source defaults to Trakt for unset or invalid storage`() { + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(null)) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("")) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("not-a-source")) + } + + @Test + fun `library source restores valid storage values`() { + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT")) + assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL")) + } + @Test fun `continue watching cap normalizes finite windows and all history`() { assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) @@ -34,4 +48,20 @@ class TraktSettingsRepositoryTest { assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC)) assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT)) } + + @Test + fun `effective library source uses Trakt only when authenticated and selected`() { + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(isAuthenticated = false, source = LibrarySourceMode.TRAKT), + ) + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.LOCAL), + ) + assertEquals( + LibrarySourceMode.TRAKT, + effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT), + ) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 8e8a1418..71d71168 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -45,6 +45,7 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_audience", "trakt_auth_payload", "trakt_library_payload", + "trakt_settings_payload", ) actual fun wipe() { From 2af53f416d4a6723bad7cc7ae1acb0e12c3fc1bc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 14:31:13 +0530 Subject: [PATCH 05/18] feat: blur unwatched episode --- .../composeResources/values/strings.xml | 4 ++ .../app/features/details/MetaDetailsScreen.kt | 3 ++ .../details/MetaScreenSettingsRepository.kt | 20 ++++++++++ .../details/components/DetailSeriesContent.kt | 22 ++++++++-- .../com/nuvio/app/features/home/HomeScreen.kt | 36 +++++++++++++++-- .../components/HomeContinueWatchingSection.kt | 33 +++++++++++++-- .../features/player/PlayerEpisodesPanel.kt | 40 ++++++++++++++++++- .../nuvio/app/features/player/PlayerScreen.kt | 19 +++++++++ .../settings/ContinueWatchingSettingsPage.kt | 11 +++++ .../settings/MetaScreenSettingsPage.kt | 10 +++++ .../settings/SettingsFullScreenPages.kt | 1 + .../app/features/settings/SettingsScreen.kt | 2 + .../ContinueWatchingPreferencesRepository.kt | 13 ++++++ .../watchprogress/WatchProgressModels.kt | 1 + .../nuvio/app/features/home/HomeScreenTest.kt | 23 +++++++++++ 15 files changed, 226 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 5c657824..b6f26e87 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -506,6 +506,8 @@ Show value Show a popup to continue where you left off when opening the app after leaving from the player. Resume prompt on launch + Blur next episode thumbnails in Continue Watching to avoid spoilers. + Blur Unwatched in Continue Watching Poster Card Style ON LAUNCH UP NEXT BEHAVIOR @@ -557,6 +559,8 @@ Detail-first stacked cards Episodes Seasons and episode list for series. + Blur Unwatched Episodes + Blur episode thumbnails until watched to avoid spoilers. Group %1$d More like this TMDB recommendation backdrops on detail page diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index b4f31fe6..80c724a3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -690,6 +690,7 @@ fun MetaDetailsScreen( onTrailerClick = resolveTrailer, progressByVideoId = watchProgressUiState.byVideoId, watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onOpenMeta = onOpenMeta, @@ -970,6 +971,7 @@ private fun ConfiguredMetaSections( onTrailerClick: (MetaTrailer) -> Unit, progressByVideoId: Map, watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, onEpisodeClick: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, @@ -1062,6 +1064,7 @@ private fun ConfiguredMetaSections( episodeCardStyle = settings.episodeCardStyle, progressByVideoId = progressByVideoId, watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index 22f1d1eb..8d4f8c0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState( val cinematicBackground: Boolean = false, val tabLayout: Boolean = false, val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, + val blurUnwatchedEpisodes: Boolean = false, ) enum class MetaEpisodeCardStyle { @@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload( @SerialName("tvStyleLayout") val tabLayout: Boolean = false, val episodeCardStyle: String = "horizontal", + @SerialName("blur_unwatched_episodes") + val blurUnwatchedEpisodes: Boolean = false, ) private data class MetaScreenSectionDefinition( @@ -156,6 +159,7 @@ object MetaScreenSettingsRepository { private var cinematicBackground: Boolean = false private var tabLayout: Boolean = false private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal + private var blurUnwatchedEpisodes: Boolean = false private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } fun ensureLoaded() { @@ -172,6 +176,7 @@ object MetaScreenSettingsRepository { tabLayout = parsed.tabLayout episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle) ?: MetaEpisodeCardStyle.Horizontal + blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes preferences = parsed.items.mapNotNull { item -> val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null key to item @@ -190,6 +195,7 @@ object MetaScreenSettingsRepository { cinematicBackground = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal + blurUnwatchedEpisodes = false _uiState.value = MetaScreenSettingsUiState() ensureLoaded() } @@ -215,6 +221,13 @@ object MetaScreenSettingsRepository { persist() } + fun setBlurUnwatchedEpisodes(enabled: Boolean) { + ensureLoaded() + blurUnwatchedEpisodes = enabled + publish() + persist() + } + fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) { ensureLoaded() if (!key.canBeTabbed) return @@ -233,6 +246,8 @@ object MetaScreenSettingsRepository { preferences.clear() cinematicBackground = false tabLayout = false + episodeCardStyle = MetaEpisodeCardStyle.Horizontal + blurUnwatchedEpisodes = false _uiState.value = MetaScreenSettingsUiState() } @@ -241,11 +256,13 @@ object MetaScreenSettingsRepository { cinematicBackground: Boolean, tabLayout: Boolean, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, + blurUnwatchedEpisodes: Boolean = false, ) { ensureLoaded() this.cinematicBackground = cinematicBackground this.tabLayout = tabLayout this.episodeCardStyle = episodeCardStyle + this.blurUnwatchedEpisodes = blurUnwatchedEpisodes preferences = items.associate { item -> item.key to StoredMetaScreenSectionPreference( key = item.key.name, @@ -271,6 +288,7 @@ object MetaScreenSettingsRepository { cinematicBackground = false tabLayout = false episodeCardStyle = MetaEpisodeCardStyle.Horizontal + blurUnwatchedEpisodes = false normalizePreferences() publish() persist() @@ -337,6 +355,7 @@ object MetaScreenSettingsRepository { cinematicBackground = cinematicBackground, tabLayout = tabLayout, episodeCardStyle = episodeCardStyle, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, ) } @@ -348,6 +367,7 @@ object MetaScreenSettingsRepository { cinematicBackground = cinematicBackground, tabLayout = tabLayout, episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle), + blurUnwatchedEpisodes = blurUnwatchedEpisodes, ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 485c729a..10f42141 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -90,6 +91,7 @@ fun DetailSeriesContent( episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, progressByVideoId: Map = emptyMap(), watchedKeys: Set = emptySet(), + blurUnwatchedEpisodes: Boolean = false, onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, ) { @@ -276,6 +278,7 @@ fun DetailSeriesContent( watchedKeys = watchedKeys, fallbackImage = meta.background ?: meta.poster, progressByVideoId = progressByVideoId, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, preferredEpisodeNumber = preferredEpisodeNumber, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, @@ -295,13 +298,14 @@ fun DetailSeriesContent( video = episode, fallbackImage = meta.background ?: meta.poster, progressEntry = progressByVideoId[episodeVideoId], - isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || + isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || WatchingState.isEpisodeWatched( watchedKeys = watchedKeys, metaType = meta.type, metaId = meta.id, episode = episode, ), + blurUnwatchedEpisodes = blurUnwatchedEpisodes, sizing = sizing, onClick = { onEpisodeClick?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) }, @@ -553,6 +557,7 @@ private fun EpisodeHorizontalRow( watchedKeys: Set, fallbackImage: String?, progressByVideoId: Map, + blurUnwatchedEpisodes: Boolean, preferredEpisodeNumber: Int? = null, onEpisodeClick: ((MetaVideo) -> Unit)?, onEpisodeLongPress: ((MetaVideo) -> Unit)?, @@ -597,13 +602,14 @@ private fun EpisodeHorizontalRow( video = episode, fallbackImage = fallbackImage, progressEntry = progressByVideoId[episodeVideoId], - isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || + isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || WatchingState.isEpisodeWatched( watchedKeys = watchedKeys, metaType = metaType, metaId = parentMetaId, episode = episode, ), + blurUnwatchedEpisodes = blurUnwatchedEpisodes, metrics = rowMetrics, onClick = { onEpisodeClick?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) }, @@ -619,6 +625,7 @@ private fun EpisodeHorizontalCard( fallbackImage: String?, progressEntry: WatchProgressEntry?, isWatched: Boolean, + blurUnwatchedEpisodes: Boolean, metrics: EpisodeHorizontalCardMetrics, onClick: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null, @@ -642,11 +649,14 @@ private fun EpisodeHorizontalCard( ), ) { val imageUrl = video.thumbnail ?: fallbackImage + val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched if (imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = video.title, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } @@ -889,6 +899,7 @@ private fun EpisodeListCard( fallbackImage: String?, progressEntry: WatchProgressEntry?, isWatched: Boolean, + blurUnwatchedEpisodes: Boolean, sizing: SeriesContentSizing, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, @@ -923,11 +934,14 @@ private fun EpisodeListCard( .clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)), ) { val imageUrl = video.thumbnail ?: fallbackImage + val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched if (imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = video.title, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 644295bd..d0144ead 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -166,6 +166,9 @@ fun HomeScreen( ) } } + val completedSeriesContentIds = remember(completedSeriesCandidates) { + completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } + } val visibleContinueWatchingEntries = remember( effectiveWatchProgressEntries, latestCompletedBySeries, @@ -181,8 +184,21 @@ fun HomeScreen( var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf>>(emptyMap()) } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } - val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) { + val cachedNextUpItems = remember( + cachedSnapshots.first, + continueWatchingPreferences.dismissedNextUpKeys, + completedSeriesContentIds, + isTraktProgressActive, + watchedUiState.isLoaded, + ) { cachedSnapshots.first.mapNotNull { cached -> + if ( + !isTraktProgressActive && + watchedUiState.isLoaded && + cached.contentId !in completedSeriesContentIds + ) { + return@mapNotNull null + } if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { return@mapNotNull null } @@ -431,6 +447,7 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -454,6 +471,7 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -496,6 +514,7 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -584,6 +603,13 @@ internal fun buildHomeContinueWatchingItems( cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, ): List { + val inProgressSeriesIds = visibleEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() + return buildList { addAll( visibleEntries.map { entry -> @@ -596,7 +622,8 @@ internal fun buildHomeContinueWatchingItems( }, ) addAll( - nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) -> + nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) -> + if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null HomeContinueWatchingCandidate( lastUpdatedEpochMs = lastUpdatedEpochMs, item = item, @@ -610,10 +637,13 @@ internal fun buildHomeContinueWatchingItems( .thenByDescending { it.isProgressEntry }, ) .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } - .distinctBy { it.item.videoId } + .distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } } .map(HomeContinueWatchingCandidate::item) } +private fun String?.isSeriesTypeForContinueWatching(): Boolean = + equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) + private data class CompletedSeriesCandidate( val content: WatchingContentRef, val seasonNumber: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index b84db29a..dc06dd62 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -54,6 +55,7 @@ private fun continueWatchingProgressPercent(progressFraction: Float): Int = internal fun HomeContinueWatchingSection( items: List, style: ContinueWatchingSectionStyle, + blurNextUp: Boolean = false, modifier: Modifier = Modifier, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, @@ -66,6 +68,7 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, layout = layout, @@ -77,6 +80,7 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), @@ -91,6 +95,7 @@ internal fun HomeContinueWatchingSection( private fun HomeContinueWatchingSectionContent( items: List, style: ContinueWatchingSectionStyle, + blurNextUp: Boolean, modifier: Modifier, sectionPadding: Dp, layout: ContinueWatchingLayout, @@ -110,12 +115,14 @@ private fun HomeContinueWatchingSectionContent( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( item = item, layout = layout, + blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, ) ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( item = item, layout = layout, + blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, ) @@ -273,6 +280,7 @@ private fun PosterCardPreview() { private fun ContinueWatchingWideCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -293,10 +301,16 @@ private fun ContinueWatchingWideCard( onLongClick = onLongClick, ), ) { - val artworkUrl = item.poster ?: item.background ?: item.imageUrl + val shouldBlurArtwork = blurNextUp && item.isNextUp + val artworkUrl = if (shouldBlurArtwork) { + item.episodeThumbnail ?: item.imageUrl ?: item.background ?: item.poster + } else { + item.poster ?: item.background ?: item.imageUrl + } ArtworkPanel( imageUrl = artworkUrl, width = layout.widePosterStripWidth, + blurred = shouldBlurArtwork, modifier = Modifier.fillMaxHeight(), ) Column( @@ -384,6 +398,7 @@ private fun ContinueWatchingWideCard( private fun ContinueWatchingPosterCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -404,12 +419,19 @@ private fun ContinueWatchingPosterCard( ) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { - val imageUrl = item.poster ?: item.imageUrl + val shouldBlurArtwork = blurNextUp && item.isNextUp + val imageUrl = if (shouldBlurArtwork) { + item.episodeThumbnail ?: item.imageUrl ?: item.poster + } else { + item.poster ?: item.imageUrl + } if (imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = item.title, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } @@ -489,6 +511,7 @@ private fun ContinueWatchingPosterCard( private fun ArtworkPanel( imageUrl: String?, width: Dp, + blurred: Boolean = false, modifier: Modifier = Modifier, ) { Box( @@ -500,7 +523,9 @@ private fun ArtworkPanel( AsyncImage( model = imageUrl, contentDescription = null, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(if (blurred) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 032fc605..69eb462e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -60,6 +61,9 @@ import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.features.watching.application.WatchingState import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource fun PlayerEpisodesPanel( visible: Boolean, episodes: List, + parentMetaType: String, + parentMetaId: String, currentSeason: Int?, currentEpisode: Int?, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, // episode stream sub-view state episodeStreamsState: EpisodeStreamsPanelState, onSeasonSelected: (Int) -> Unit, @@ -134,8 +143,13 @@ fun PlayerEpisodesPanel( } else { EpisodesListSubView( episodes = episodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, currentSeason = currentSeason, currentEpisode = currentEpisode, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, onSeasonSelected = onSeasonSelected, onEpisodeSelected = onEpisodeSelected, onDismiss = onDismiss, @@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState( @Composable private fun EpisodesListSubView( episodes: List, + parentMetaType: String, + parentMetaId: String, currentSeason: Int?, currentEpisode: Int?, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, onDismiss: () -> Unit, @@ -296,9 +315,24 @@ private fun EpisodesListSubView( key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, ) { _, episode -> val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode + val episodeVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + val isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || + WatchingState.isEpisodeWatched( + watchedKeys = watchedKeys, + metaType = parentMetaType, + metaId = parentMetaId, + episode = episode, + ) EpisodeRow( episode = episode, isCurrent = isCurrent, + isWatched = isWatched, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, onClick = { onEpisodeSelected(episode) }, ) } @@ -311,9 +345,12 @@ private fun EpisodesListSubView( private fun EpisodeRow( episode: MetaVideo, isCurrent: Boolean, + isWatched: Boolean, + blurUnwatchedEpisodes: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent Row( modifier = Modifier @@ -342,7 +379,8 @@ private fun EpisodeRow( modifier = Modifier .width(80.dp) .height(48.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(RoundedCornerShape(8.dp)) + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e19e11b7..fc24fba4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.downloads.DownloadItem import com.nuvio.app.features.downloads.DownloadsRepository @@ -55,6 +56,7 @@ import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -143,6 +145,18 @@ fun PlayerScreen( PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() + val watchProgressUiState by remember { + WatchProgressRepository.ensureLoaded() + WatchProgressRepository.uiState + }.collectAsStateWithLifecycle() BoxWithConstraints( modifier = modifier @@ -1799,8 +1813,13 @@ fun PlayerScreen( PlayerEpisodesPanel( visible = showEpisodesPanel, episodes = allEpisodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, currentSeason = activeSeasonNumber, currentEpisode = activeEpisodeNumber, + progressByVideoId = watchProgressUiState.byVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, episodeStreamsState = episodeStreamsPanelState.copy( streamsUiState = episodeStreamsRepoState, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 34ab403c..bada31ad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -28,6 +28,8 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title +import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description +import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior @@ -48,6 +50,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + blurNextUp: Boolean, showResumePromptOnLaunch: Boolean, ) { item { @@ -91,6 +94,14 @@ internal fun LazyListScope.continueWatchingSettingsContent( isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_continue_watching_blur_next_up_title), + description = stringResource(Res.string.settings_continue_watching_blur_next_up_description), + checked = blurNextUp, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index adfd3e02..ac932b93 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -78,6 +78,8 @@ import nuvio.composeapp.generated.resources.settings_meta_episode_style_list import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description import nuvio.composeapp.generated.resources.settings_meta_episodes import nuvio.composeapp.generated.resources.settings_meta_episodes_description +import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes +import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description import nuvio.composeapp.generated.resources.settings_meta_group_label import nuvio.composeapp.generated.resources.settings_meta_more_like_this import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description @@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent( selectedStyle = uiState.episodeCardStyle, onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_meta_blur_unwatched_episodes), + description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description), + checked = uiState.blurUnwatchedEpisodes, + isTablet = isTablet, + onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index 4dbd27d2..4ff8d7ab 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -127,6 +127,7 @@ fun ContinueWatchingSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 2ed86c15..a180d66d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -376,6 +376,7 @@ private fun MobileSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( @@ -614,6 +615,7 @@ private fun TabletSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt index 5e0eb093..f0d70f49 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -13,6 +14,8 @@ private data class StoredContinueWatchingPreferences( val isVisible: Boolean = true, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val upNextFromFurthestEpisode: Boolean = true, + @SerialName("blur_continue_watching_next_up") + val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, ) @@ -46,6 +49,7 @@ object ContinueWatchingPreferencesRepository { isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + blurNextUp: Boolean = false, dismissedNextUpKeys: Set, ) { ensureLoaded() @@ -53,6 +57,7 @@ object ContinueWatchingPreferencesRepository { isVisible = isVisible, style = style, upNextFromFurthestEpisode = upNextFromFurthestEpisode, + blurNextUp = blurNextUp, dismissedNextUpKeys = dismissedNextUpKeys .map(String::trim) .filter(String::isNotBlank) @@ -79,6 +84,7 @@ object ContinueWatchingPreferencesRepository { isVisible = stored.isVisible, style = stored.style, upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode, + blurNextUp = stored.blurNextUp, dismissedNextUpKeys = stored.dismissedNextUpKeys, showResumePromptOnLaunch = stored.showResumePromptOnLaunch, ) @@ -105,6 +111,12 @@ object ContinueWatchingPreferencesRepository { persist() } + fun setBlurNextUp(enabled: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(blurNextUp = enabled) + persist() + } + fun addDismissedNextUpKey(key: String) { ensureLoaded() val normalizedKey = key.trim() @@ -139,6 +151,7 @@ object ContinueWatchingPreferencesRepository { isVisible = _uiState.value.isVisible, style = _uiState.value.style, upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode, + blurNextUp = _uiState.value.blurNextUp, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 12efbd73..92cbfc06 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -163,6 +163,7 @@ data class ContinueWatchingPreferencesUiState( val isVisible: Boolean = true, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val upNextFromFurthestEpisode: Boolean = true, + val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 51da33ff..bb98bcbb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -61,6 +61,29 @@ class HomeScreenTest { assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) } + @Test + fun `build home continue watching items suppresses next up when series has in progress resume`() { + val inProgress = progressEntry( + videoId = "show:1:4", + title = "Show", + episodeNumber = 4, + episodeTitle = "Current", + lastUpdatedEpochMs = 200L, + ) + val nextUp = continueWatchingItem( + videoId = "show:1:5", + subtitle = "Up Next • S1E5 • Next", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(inProgress), + nextUpItemsBySeries = mapOf("show" to (500L to nextUp)), + ) + + assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId)) + assertEquals("S1E4 • Current", result.single().subtitle) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( From d4878dbd2e0ff4865066c099245d03843e248dfc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 15:23:38 +0530 Subject: [PATCH 06/18] ref: cw optimizations to be in parity with tv --- .../composeResources/values/strings.xml | 4 + .../details/SeriesPlaybackResolver.kt | 3 +- .../com/nuvio/app/features/home/HomeScreen.kt | 26 ++- .../components/HomeContinueWatchingSection.kt | 56 +++++-- .../settings/ContinueWatchingSettingsPage.kt | 32 +++- .../settings/SettingsFullScreenPages.kt | 2 + .../app/features/settings/SettingsScreen.kt | 4 + .../features/trakt/TraktProgressRepository.kt | 155 ++++++++++++++++-- .../app/features/watched/WatchedModels.kt | 40 +++++ .../app/features/watched/WatchedRepository.kt | 73 ++++++++- .../watching/application/WatchingState.kt | 26 +-- .../sync/SupabaseProgressSyncAdapter.kt | 4 +- .../sync/SupabaseWatchedSyncAdapter.kt | 5 +- .../watching/sync/TraktWatchedSyncAdapter.kt | 26 ++- .../ContinueWatchingEnrichmentCache.kt | 2 + .../ContinueWatchingPreferencesRepository.kt | 24 +++ .../watchprogress/WatchProgressModels.kt | 13 +- .../watchprogress/WatchProgressRepository.kt | 7 +- .../watchprogress/WatchProgressRules.kt | 39 ++++- .../app/features/watched/WatchedModelsTest.kt | 44 +++++ .../watching/application/WatchingStateTest.kt | 104 ++++++++++++ .../watchprogress/WatchProgressRulesTest.kt | 57 +++++++ 22 files changed, 662 insertions(+), 84 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b6f26e87..6fedd1f7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -508,6 +508,8 @@ Resume prompt on launch Blur next episode thumbnails in Continue Watching to avoid spoilers. Blur Unwatched in Continue Watching + Include upcoming episodes in Continue Watching before they air. + Show Unaired Next Up Episodes Poster Card Style ON LAUNCH UP NEXT BEHAVIOR @@ -520,6 +522,8 @@ Info-dense horizontal card Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead. Up Next From Furthest Episode + Use episode thumbnails as default image. When disabled, uses backdrop. + Use Episode Thumbnails in Continue Watching HOME SOURCES Install, remove, refresh, and sort your content sources. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index bf4b6744..ac964731 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingContentRef @@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord = content = WatchingContentRef(type = type, id = id), seasonNumber = season, episodeNumber = episode, - markedAtEpochMs = markedAtEpochMs, + markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs), ) private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index d0144ead..b7fa9134 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -40,6 +40,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -49,6 +50,7 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef +import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection @@ -189,6 +191,7 @@ fun HomeScreen( continueWatchingPreferences.dismissedNextUpKeys, completedSeriesContentIds, isTraktProgressActive, + continueWatchingPreferences.showUnairedNextUp, watchedUiState.isLoaded, ) { cachedSnapshots.first.mapNotNull { cached -> @@ -202,6 +205,9 @@ fun HomeScreen( if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { return@mapNotNull null } + if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { + return@mapNotNull null + } val item = cached.toContinueWatchingItem() ?: return@mapNotNull null cached.contentId to (cached.sortTimestamp to item) }.toMap() @@ -280,7 +286,11 @@ fun HomeScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) { + LaunchedEffect( + completedSeriesCandidates, + metaProviderKey, + continueWatchingPreferences.showUnairedNextUp, + ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() return@LaunchedEffect @@ -301,7 +311,7 @@ fun HomeScreen( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, - showUnairedNextUp = isTraktProgressActive, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) ?: return@withPermit null val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) @@ -329,6 +339,10 @@ fun HomeScreen( episodeTitle = item.episodeTitle, episodeThumbnail = item.episodeThumbnail, pauseDescription = item.pauseDescription, + released = item.released, + hasAired = item.released?.let { released -> + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) + } ?: true, lastWatched = pair.first, sortTimestamp = pair.first, seedSeason = item.nextUpSeedSeasonNumber, @@ -447,6 +461,7 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, @@ -471,6 +486,7 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, @@ -514,6 +530,7 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, @@ -641,9 +658,6 @@ internal fun buildHomeContinueWatchingItems( .map(HomeContinueWatchingCandidate::item) } -private fun String?.isSeriesTypeForContinueWatching(): Boolean = - equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) - private data class CompletedSeriesCandidate( val content: WatchingContentRef, val seasonNumber: Int, @@ -698,6 +712,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, + released = released, isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -764,5 +779,6 @@ private fun ContinueWatchingItem.withFallbackMetadata( episodeTitle = episodeTitle ?: fallback.episodeTitle, episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, pauseDescription = pauseDescription ?: fallback.pauseDescription, + released = released ?: fallback.released, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index dc06dd62..c3f44947 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -51,10 +51,43 @@ import org.jetbrains.compose.resources.stringResource private fun continueWatchingProgressPercent(progressFraction: Float): Int = (progressFraction * 100f).roundToInt().coerceIn(1, 99) +private fun ContinueWatchingItem.continueWatchingArtworkUrl( + useEpisodeThumbnails: Boolean, +): String? = when { + isNextUp && useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + background, + poster, + imageUrl, + ) + isNextUp -> firstNonBlank( + background, + poster, + episodeThumbnail, + imageUrl, + ) + useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + background, + poster, + imageUrl, + ) + else -> firstNonBlank( + background, + poster, + episodeThumbnail, + imageUrl, + ) +} + +private fun firstNonBlank(vararg values: String?): String? = + values.firstOrNull { value -> !value.isNullOrBlank() }?.trim() + @Composable internal fun HomeContinueWatchingSection( items: List, style: ContinueWatchingSectionStyle, + useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, sectionPadding: Dp? = null, @@ -68,6 +101,7 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, @@ -80,6 +114,7 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), @@ -95,6 +130,7 @@ internal fun HomeContinueWatchingSection( private fun HomeContinueWatchingSectionContent( items: List, style: ContinueWatchingSectionStyle, + useEpisodeThumbnails: Boolean, blurNextUp: Boolean, modifier: Modifier, sectionPadding: Dp, @@ -115,6 +151,7 @@ private fun HomeContinueWatchingSectionContent( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( item = item, layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, @@ -122,6 +159,7 @@ private fun HomeContinueWatchingSectionContent( ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( item = item, layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, @@ -280,6 +318,7 @@ private fun PosterCardPreview() { private fun ContinueWatchingWideCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + useEpisodeThumbnails: Boolean, blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, @@ -301,12 +340,8 @@ private fun ContinueWatchingWideCard( onLongClick = onLongClick, ), ) { - val shouldBlurArtwork = blurNextUp && item.isNextUp - val artworkUrl = if (shouldBlurArtwork) { - item.episodeThumbnail ?: item.imageUrl ?: item.background ?: item.poster - } else { - item.poster ?: item.background ?: item.imageUrl - } + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) ArtworkPanel( imageUrl = artworkUrl, width = layout.widePosterStripWidth, @@ -398,6 +433,7 @@ private fun ContinueWatchingWideCard( private fun ContinueWatchingPosterCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + useEpisodeThumbnails: Boolean, blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, @@ -419,12 +455,8 @@ private fun ContinueWatchingPosterCard( ) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { - val shouldBlurArtwork = blurNextUp && item.isNextUp - val imageUrl = if (shouldBlurArtwork) { - item.episodeThumbnail ?: item.imageUrl ?: item.poster - } else { - item.poster ?: item.imageUrl - } + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) if (imageUrl != null) { AsyncImage( model = imageUrl, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index bada31ad..c3d81354 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -30,6 +30,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_resume_pr import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title +import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_description +import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior @@ -42,6 +44,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_style_wid import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title +import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_description +import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_title import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -50,6 +54,8 @@ internal fun LazyListScope.continueWatchingSettingsContent( isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean, + showUnairedNextUp: Boolean, blurNextUp: Boolean, showResumePromptOnLaunch: Boolean, ) { @@ -87,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), + description = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description), + checked = useEpisodeThumbnails, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setUseEpisodeThumbnails, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_continue_watching_up_next_title), description = stringResource(Res.string.settings_continue_watching_up_next_description), @@ -96,12 +110,22 @@ internal fun LazyListScope.continueWatchingSettingsContent( ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = stringResource(Res.string.settings_continue_watching_blur_next_up_title), - description = stringResource(Res.string.settings_continue_watching_blur_next_up_description), - checked = blurNextUp, + title = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), + description = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description), + checked = showUnairedNextUp, isTablet = isTablet, - onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp, + onCheckedChange = ContinueWatchingPreferencesRepository::setShowUnairedNextUp, ) + if (useEpisodeThumbnails) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_continue_watching_blur_next_up_title), + description = stringResource(Res.string.settings_continue_watching_blur_next_up_description), + checked = blurNextUp, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index 4ff8d7ab..b8cd870d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -127,6 +127,8 @@ fun ContinueWatchingSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index a180d66d..9818b247 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -376,6 +376,8 @@ private fun MobileSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) @@ -615,6 +617,8 @@ private fun TabletSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 6ca02f0b..de3e429f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -4,8 +4,13 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" -private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 80f +private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f private const val HISTORY_LIMIT = 250 private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 @@ -113,8 +118,8 @@ object TraktProgressRepository { } scope.launch { - val historyEntries = runCatching { - fetchHistoryEntries(headers) + val completedEntries = runCatching { + fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers) }.onFailure { error -> if (error is CancellationException) throw error log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } @@ -122,7 +127,7 @@ object TraktProgressRepository { if (!isLatestRefreshRequest(requestId)) return@launch - val merged = mergeNewestByVideoId(playbackEntries + historyEntries) + val merged = mergeNewestByVideoId(playbackEntries + completedEntries) _uiState.value = _uiState.value.copy( entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, isLoading = false, @@ -345,12 +350,32 @@ object TraktProgressRepository { mergeNewestByVideoId(completedEpisodes + completedMovies) } + private suspend fun fetchWatchedShowSeedEntries( + headers: Map, + ): List = withContext(Dispatchers.Default) { + ContinueWatchingPreferencesRepository.ensureLoaded() + val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode + val payload = httpGetTextWithHeaders( + url = "$BASE_URL/sync/watched/shows", + headers = headers, + ) + val watchedShows = json.decodeFromString>(payload) + watchedShows + .mapNotNull { item -> + mapWatchedShowSeed( + item = item, + useFurthestEpisode = useFurthestEpisode, + ) + } + .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + } + private fun mergeNewestByVideoId(entries: List): List { val mergedByVideoId = linkedMapOf() entries.forEach { rawEntry -> val entry = rawEntry.normalizedCompletion() val existing = mergedByVideoId[entry.videoId] - if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { + if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) { mergedByVideoId[entry.videoId] = entry } } @@ -360,6 +385,18 @@ object TraktProgressRepository { .sortedByDescending { it.lastUpdatedEpochMs } } + private fun shouldReplaceProgressSnapshotEntry( + existing: WatchProgressEntry, + candidate: WatchProgressEntry, + ): Boolean { + val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching() + val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching() + if (existingInProgress != candidateInProgress) { + return candidateInProgress + } + return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs + } + private fun mergeEntriesPreferRichMetadata( current: List, hydrated: List, @@ -499,6 +536,7 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, progressPercent = progressPercent, + source = WatchProgressSourceTraktPlayback, ).normalizedCompletion() } @@ -533,6 +571,7 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, progressPercent = progressPercent, + source = WatchProgressSourceTraktPlayback, ).normalizedCompletion() } @@ -564,6 +603,7 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, progressPercent = 100f, + source = WatchProgressSourceTraktHistory, ) } @@ -583,6 +623,73 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + } + + private fun mapWatchedShowSeed( + item: TraktWatchedShowItem, + useFurthestEpisode: Boolean, + ): WatchProgressEntry? { + val show = item.show ?: return null + val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) + if (parentMetaId.isBlank()) return null + + val completedEpisode = item.seasons.orEmpty() + .asSequence() + .filter { season -> (season.number ?: 0) > 0 } + .flatMap { season -> + val seasonNumber = season.number ?: return@flatMap emptySequence() + season.episodes.orEmpty() + .asSequence() + .filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 } + .mapNotNull { episode -> + val episodeNumber = episode.number ?: return@mapNotNull null + TraktWatchedShowEpisodeSeed( + season = seasonNumber, + episode = episodeNumber, + watchedAt = rankedTimestamp( + isoDate = episode.lastWatchedAt ?: item.lastWatchedAt, + fallbackIndex = 0, + ), + ) + } + } + .maxWithOrNull( + if (useFurthestEpisode) { + compareBy( + { it.season }, + { it.episode }, + { it.watchedAt }, + ) + } else { + compareBy( + { it.watchedAt }, + { it.season }, + { it.episode }, + ) + }, + ) ?: return null + + return WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = completedEpisode.season, + episodeNumber = completedEpisode.episode, + fallbackVideoId = null, + ), + title = show.title ?: parentMetaId, + seasonNumber = completedEpisode.season, + episodeNumber = completedEpisode.episode, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = completedEpisode.watchedAt, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktShowProgress, ) } @@ -597,14 +704,10 @@ object TraktProgressRepository { } private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long { - val compactDigits = isoDate - ?.filter(Char::isDigit) - ?.take(14) - ?.takeIf { it.length >= 8 } - ?.padEnd(14, '0') - ?.toLongOrNull() - if (compactDigits != null) return compactDigits - + isoDate + ?.takeIf { it.isNotBlank() } + ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs) + ?.let { return it } return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) } } @@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem( @SerialName("movie") val movie: TraktMedia? = null, ) +@Serializable +private data class TraktWatchedShowItem( + @SerialName("last_watched_at") val lastWatchedAt: String? = null, + @SerialName("show") val show: TraktMedia? = null, + @SerialName("seasons") val seasons: List? = null, +) + +@Serializable +private data class TraktWatchedShowSeason( + @SerialName("number") val number: Int? = null, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktWatchedShowEpisode( + @SerialName("number") val number: Int? = null, + @SerialName("plays") val plays: Int? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, +) + +private data class TraktWatchedShowEpisodeSeed( + val season: Int, + val episode: Int, + val watchedAt: Long, +) + @Serializable private data class TraktMedia( @SerialName("title") val title: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 6ade3728..3f778d81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.watchedKey import kotlinx.serialization.Serializable @@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem = val WatchedItem.isEpisode: Boolean get() = season != null && episode != null +internal fun WatchedItem.normalizedMarkedAt(): WatchedItem { + val normalized = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs) + return if (normalized == markedAtEpochMs) this else copy(markedAtEpochMs = normalized) +} + +internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long { + if (value !in CompactWatchedTimestampMin..CompactWatchedTimestampMax) return value + + val raw = value.toString().padStart(14, '0') + val year = raw.substring(0, 4).toIntOrNull() ?: return value + val month = raw.substring(4, 6).toIntOrNull() ?: return value + val day = raw.substring(6, 8).toIntOrNull() ?: return value + val hour = raw.substring(8, 10).toIntOrNull() ?: return value + val minute = raw.substring(10, 12).toIntOrNull() ?: return value + val second = raw.substring(12, 14).toIntOrNull() ?: return value + + if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) { + return value + } + + val iso = buildString { + append(year.toString().padStart(4, '0')) + append('-') + append(month.toString().padStart(2, '0')) + append('-') + append(day.toString().padStart(2, '0')) + append('T') + append(hour.toString().padStart(2, '0')) + append(':') + append(minute.toString().padStart(2, '0')) + append(':') + append(second.toString().padStart(2, '0')) + append('Z') + } + return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value +} + fun watchedItemKey( type: String, id: String, @@ -47,3 +85,5 @@ fun watchedItemKey( episodeNumber = episode, ) +private const val CompactWatchedTimestampMin = 19000101000000L +private const val CompactWatchedTimestampMax = 29991231235959L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 8cc9056c..c2ae8997 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedSyncAdapter @@ -42,8 +45,8 @@ object WatchedRepository { private var itemsByKey: MutableMap = mutableMapOf() internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter - private fun activeSyncAdapter(): WatchedSyncAdapter = - if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter + private fun activePullSyncAdapter(): WatchedSyncAdapter = + if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter fun ensureLoaded() { if (hasLoaded) return @@ -72,21 +75,27 @@ object WatchedRepository { val items = runCatching { json.decodeFromString(payload).items }.getOrDefault(emptyList()) - itemsByKey = items.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap() + itemsByKey = items + .map(WatchedItem::normalizedMarkedAt) + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + .toMutableMap() } publish() } suspend fun pullFromServer(profileId: Int) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() currentProfileId = profileId runCatching { - val serverItems = activeSyncAdapter().pull( + val serverItems = activePullSyncAdapter().pull( profileId = profileId, pageSize = watchedItemsPageSize, ) itemsByKey = serverItems + .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() hasLoaded = true @@ -203,7 +212,7 @@ object WatchedRepository { runCatching { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - activeSyncAdapter().push(profileId = profileId, items = items) + pushToActiveTargets(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched items" } } @@ -215,7 +224,7 @@ object WatchedRepository { runCatching { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - activeSyncAdapter().delete(profileId = profileId, items = items) + deleteFromActiveTargets(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched item delete" } } @@ -223,7 +232,9 @@ object WatchedRepository { } private fun publish() { - val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs } + val items = itemsByKey.values + .map(WatchedItem::normalizedMarkedAt) + .sortedByDescending { it.markedAtEpochMs } _uiState.value = WatchedUiState( items = items, watchedKeys = items.mapTo(linkedSetOf()) { @@ -238,9 +249,55 @@ object WatchedRepository { currentProfileId, json.encodeToString( StoredWatchedPayload( - items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }, + items = itemsByKey.values + .map(WatchedItem::normalizedMarkedAt) + .sortedByDescending { it.markedAtEpochMs }, ), ), ) } + + private fun shouldUseTraktWatchedSync(): Boolean = + shouldUseTraktWatchedSync( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) + + private suspend fun pushToActiveTargets( + profileId: Int, + items: Collection, + ) { + if (shouldUseTraktWatchedSync()) { + TraktWatchedSyncAdapter.push(profileId = profileId, items = items) + return + } + + syncAdapter.push(profileId = profileId, items = items) + if (TraktAuthRepository.isAuthenticated.value) { + TraktWatchedSyncAdapter.push(profileId = profileId, items = items) + } + } + + private suspend fun deleteFromActiveTargets( + profileId: Int, + items: Collection, + ) { + if (shouldUseTraktWatchedSync()) { + TraktWatchedSyncAdapter.delete(profileId = profileId, items = items) + return + } + + syncAdapter.delete(profileId = profileId, items = items) + if (TraktAuthRepository.isAuthenticated.value) { + TraktWatchedSyncAdapter.delete(profileId = profileId, items = items) + } + } } + +internal fun shouldUseTraktWatchedSync( + isAuthenticated: Boolean, + source: WatchProgressSource, +): Boolean = shouldUseTraktProgress( + isAuthenticated = isAuthenticated, + source = source, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index 9e29639a..c0f1474f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -3,13 +3,15 @@ package com.nuvio.app.features.watching.application import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.continueWatchingEntries +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.WatchingWatchedRecord -import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode object WatchingState { @@ -59,7 +61,9 @@ object WatchingState { add(WatchingContentRef(type = item.type, id = item.id)) } } - val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord) + val progressRecords = progressEntries + .filter { entry -> entry.shouldUseAsCompletedSeedForContinueWatching() } + .map(WatchProgressEntry::toDomainProgressRecord) val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord) return contentRefs.mapNotNull { content -> latestCompletedSeriesEpisode( @@ -73,21 +77,9 @@ object WatchingState { fun visibleContinueWatchingEntries( progressEntries: List, + @Suppress("UNUSED_PARAMETER") latestCompletedBySeries: Map, - ): List { - val visibleIds = continueWatchingProgressEntries( - progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord), - ) - .filter { record -> - val latestCompleted = latestCompletedBySeries[record.content] - latestCompleted == null || record.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs - } - .mapTo(linkedSetOf()) { record -> record.videoId } - - return progressEntries - .filter { entry -> entry.videoId in visibleIds } - .sortedByDescending { entry -> entry.lastUpdatedEpochMs } - } + ): List = progressEntries.continueWatchingEntries() } private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = @@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord = content = WatchingContentRef(type = type, id = id), seasonNumber = season, episodeNumber = episode, - markedAtEpochMs = markedAtEpochMs, + markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 63307daf..cb2dc940 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { override suspend fun pull(profileId: Int): List { val params = buildJsonObject { put("p_profile_id", profileId) } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) - return result.decodeList().map { entry -> + val serverEntries = result.decodeList() + val records = serverEntries.map { entry -> ProgressSyncRecord( contentId = entry.contentId, contentType = entry.contentType, @@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { lastWatched = entry.lastWatched, ) } + return records } override suspend fun push( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt index 9bba34a0..cab9e553 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.serialization.SerialName @@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { name = syncItem.title, season = syncItem.season, episode = syncItem.episode, - markedAtEpochMs = syncItem.watchedAt, + markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt), ) } } @@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { title = item.name, season = item.season, episode = item.episode, - watchedAt = item.markedAtEpochMs, + watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs), ) } val params = buildJsonObject { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 5a63de88..ac647c89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -5,7 +5,9 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktEpisodeMappingService +import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { } private fun rankedTimestamp(isoDate: String?): Long { - val digits = isoDate - ?.filter(Char::isDigit) - ?.take(14) - ?.takeIf { it.length >= 8 } - ?.padEnd(14, '0') - ?.toLongOrNull() - return digits ?: 0L + return isoDate + ?.takeIf { it.isNotBlank() } + ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs) + ?: 0L } private fun epochMsToIso(epochMs: Long): String { - // Convert to a compact ISO 8601 UTC string. - // Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places, - // or a real epoch-ms. We only send when it looks like real epoch-ms. - if (epochMs <= 0L) return "unknown" - if (epochMs < 10_000_000_000L) { - // Looks like seconds-based or ranked timestamp — send unknown - return "unknown" - } + val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs) + if (normalizedEpochMs <= 0L) return "unknown" + if (normalizedEpochMs < 10_000_000_000L) return "unknown" // Real epoch ms → simple ISO via arithmetic - val totalSeconds = epochMs / 1000 + val totalSeconds = normalizedEpochMs / 1000 val s = (totalSeconds % 60).toInt() val m = ((totalSeconds / 60) % 60).toInt() val h = ((totalSeconds / 3600) % 24).toInt() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 551c78bd..6152fae8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -19,6 +19,8 @@ data class CachedNextUpItem( val episodeTitle: String? = null, val episodeThumbnail: String? = null, val pauseDescription: String? = null, + val released: String? = null, + val hasAired: Boolean = true, val lastWatched: Long, val sortTimestamp: Long, val seedSeason: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt index f0d70f49..9845b680 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt @@ -14,6 +14,10 @@ private data class StoredContinueWatchingPreferences( val isVisible: Boolean = true, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val upNextFromFurthestEpisode: Boolean = true, + @SerialName("use_episode_thumbnails_in_cw") + val useEpisodeThumbnails: Boolean = true, + @SerialName("show_unaired_next_up") + val showUnairedNextUp: Boolean = true, @SerialName("blur_continue_watching_next_up") val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), @@ -49,6 +53,8 @@ object ContinueWatchingPreferencesRepository { isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean = true, + showUnairedNextUp: Boolean = true, blurNextUp: Boolean = false, dismissedNextUpKeys: Set, ) { @@ -57,6 +63,8 @@ object ContinueWatchingPreferencesRepository { isVisible = isVisible, style = style, upNextFromFurthestEpisode = upNextFromFurthestEpisode, + useEpisodeThumbnails = useEpisodeThumbnails, + showUnairedNextUp = showUnairedNextUp, blurNextUp = blurNextUp, dismissedNextUpKeys = dismissedNextUpKeys .map(String::trim) @@ -84,6 +92,8 @@ object ContinueWatchingPreferencesRepository { isVisible = stored.isVisible, style = stored.style, upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode, + useEpisodeThumbnails = stored.useEpisodeThumbnails, + showUnairedNextUp = stored.showUnairedNextUp, blurNextUp = stored.blurNextUp, dismissedNextUpKeys = stored.dismissedNextUpKeys, showResumePromptOnLaunch = stored.showResumePromptOnLaunch, @@ -111,6 +121,18 @@ object ContinueWatchingPreferencesRepository { persist() } + fun setUseEpisodeThumbnails(enabled: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(useEpisodeThumbnails = enabled) + persist() + } + + fun setShowUnairedNextUp(enabled: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(showUnairedNextUp = enabled) + persist() + } + fun setBlurNextUp(enabled: Boolean) { ensureLoaded() _uiState.value = _uiState.value.copy(blurNextUp = enabled) @@ -151,6 +173,8 @@ object ContinueWatchingPreferencesRepository { isVisible = _uiState.value.isVisible, style = _uiState.value.style, upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode, + useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails, + showUnairedNextUp = _uiState.value.showUnairedNextUp, blurNextUp = _uiState.value.blurNextUp, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 92cbfc06..1c27213d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable -internal const val WatchProgressCompletionPercentThreshold = 99.5f +internal const val WatchProgressCompletionPercentThreshold = 90f +internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f +internal const val WatchProgressSourceLocal = "local" +internal const val WatchProgressSourceTraktPlayback = "trakt_playback" +internal const val WatchProgressSourceTraktHistory = "trakt_history" +internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress" @Serializable enum class ContinueWatchingSectionStyle { @@ -37,6 +42,7 @@ data class WatchProgressEntry( val lastSourceUrl: String? = null, val isCompleted: Boolean = false, val progressPercent: Float? = null, + val source: String = WatchProgressSourceLocal, ) { val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -150,6 +156,7 @@ data class ContinueWatchingItem( val episodeTitle: String? = null, val episodeThumbnail: String? = null, val pauseDescription: String? = null, + val released: String? = null, val isNextUp: Boolean = false, val nextUpSeedSeasonNumber: Int? = null, val nextUpSeedEpisodeNumber: Int? = null, @@ -163,6 +170,8 @@ data class ContinueWatchingPreferencesUiState( val isVisible: Boolean = true, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val upNextFromFurthestEpisode: Boolean = true, + val useEpisodeThumbnails: Boolean = true, + val showUnairedNextUp: Boolean = true, val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, @@ -205,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeTitle = normalizedEntry.episodeTitle, episodeThumbnail = normalizedEntry.episodeThumbnail, pauseDescription = normalizedEntry.pauseDescription, + released = null, isNextUp = false, nextUpSeedSeasonNumber = null, nextUpSeedEpisodeNumber = null, @@ -242,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeTitle = nextEpisode.title, episodeThumbnail = nextEpisode.thumbnail, pauseDescription = nextEpisode.overview, + released = nextEpisode.released, isNextUp = true, nextUpSeedSeasonNumber = seasonNumber, nextUpSeedEpisodeNumber = episodeNumber, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index d46c40c6..23991057 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -126,7 +126,9 @@ object WatchProgressRepository { TraktProgressRepository.ensureLoaded() currentProfileId = profileId - if (shouldUseTraktProgress()) { + val useTraktProgress = shouldUseTraktProgress() + + if (useTraktProgress) { runCatching { TraktProgressRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } publish() @@ -419,8 +421,9 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() + val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } _uiState.value = WatchProgressUiState( - entries = entries.sortedByDescending { it.lastUpdatedEpochMs }, + entries = sortedEntries, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index d12f80c2..302beece 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -67,15 +67,50 @@ internal fun List.resumeEntryForSeries(metaId: String): Watc internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { + val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } val domainEntries = continueWatchingProgressEntries( - progressRecords = map(WatchProgressEntry::toDomainProgressRecord), + progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord), limit = limit, ) val ids = domainEntries.map { record -> record.videoId }.toSet() - return filter { entry -> entry.videoId in ids } + return inProgressEntries.filter { entry -> entry.videoId in ids } .sortedByDescending { it.lastUpdatedEpochMs } } +internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Boolean { + val entry = normalizedCompletion() + if (entry.isEffectivelyCompleted) return false + + val hasStartedPlayback = entry.lastPositionMs > 0L || + entry.normalizedProgressPercent?.let { it > 0f } == true + if (!hasStartedPlayback) return false + + return entry.source != WatchProgressSourceTraktHistory && + entry.source != WatchProgressSourceTraktShowProgress +} + +internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean { + val entry = normalizedCompletion() + if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false + if (!entry.isEffectivelyCompleted) return false + if (entry.source != WatchProgressSourceTraktPlayback) return true + + val explicitPercent = entry.normalizedProgressPercent ?: return false + return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold +} + +internal fun String?.isSeriesTypeForContinueWatching(): Boolean = + equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) + +internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { + val trimmed = contentId?.trim().orEmpty() + if (trimmed.isEmpty()) return true + return when (trimmed.lowercase()) { + "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true + else -> false + } +} + private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt new file mode 100644 index 00000000..a9664e04 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.watched + +import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.features.trakt.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchedModelsTest { + @Test + fun `compact watched timestamp normalizes to epoch millis`() { + val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + + assertEquals(expected, normalizeWatchedMarkedAtEpochMs(20260425100200L)) + } + + @Test + fun `epoch watched timestamp is kept unchanged`() { + assertEquals(1_778_060_222_000L, normalizeWatchedMarkedAtEpochMs(1_778_060_222_000L)) + } + + @Test + fun `Trakt watched sync follows selected watch progress source`() { + assertTrue( + shouldUseTraktWatchedSync( + isAuthenticated = true, + source = WatchProgressSource.TRAKT, + ), + ) + assertFalse( + shouldUseTraktWatchedSync( + isAuthenticated = true, + source = WatchProgressSource.NUVIO_SYNC, + ), + ) + assertFalse( + shouldUseTraktWatchedSync( + isAuthenticated = false, + source = WatchProgressSource.TRAKT, + ), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt new file mode 100644 index 00000000..e615cbc6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -0,0 +1,104 @@ +package com.nuvio.app.features.watching.application + +import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchingStateTest { + @Test + fun `latest completed ignores Trakt playback below next up seed threshold`() { + val almostCompletePlayback = entry( + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + progressPercent = 94f, + source = WatchProgressSourceTraktPlayback, + ) + + val result = WatchingState.latestCompletedBySeries( + progressEntries = listOf(almostCompletePlayback), + watchedItems = emptyList(), + ) + + assertTrue(result.isEmpty()) + } + + @Test + fun `visible continue watching keeps active resume when newer episode is completed`() { + val resume = entry( + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + val latestCompleted = WatchingState.latestCompletedBySeries( + progressEntries = listOf(resume, completed), + watchedItems = emptyList(), + ) + + val result = WatchingState.visibleContinueWatchingEntries( + progressEntries = listOf(resume, completed), + latestCompletedBySeries = latestCompleted, + ) + + assertEquals(listOf("show:1:4"), result.map { it.videoId }) + } + + @Test + fun `latest completed normalizes compact watched timestamps before sorting`() { + val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + + val result = WatchingState.latestCompletedBySeries( + progressEntries = emptyList(), + watchedItems = listOf( + WatchedItem( + id = "show", + type = "series", + name = "Show", + season = 3, + episode = 1, + markedAtEpochMs = 20260425100200L, + ), + ), + preferFurthestEpisode = false, + ) + + assertEquals(expected, result.values.single().markedAtEpochMs) + } + + private fun entry( + videoId: String, + seasonNumber: Int?, + episodeNumber: Int?, + lastUpdatedEpochMs: Long = 1L, + isCompleted: Boolean = false, + progressPercent: Float? = null, + source: String = "local", + ): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = videoId, + title = "Show", + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + isCompleted = isCompleted, + progressPercent = progressPercent, + source = source, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index 658bdb66..bed674ef 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -118,6 +118,61 @@ class WatchProgressRulesTest { assertEquals(listOf("movie-progress"), result.map { it.videoId }) } + @Test + fun `continue watching keeps active resume even when a newer episode is completed`() { + val inProgress = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + val result = listOf(inProgress, completed).continueWatchingEntries() + + assertEquals(listOf("show:1:4"), result.map { it.videoId }) + } + + @Test + fun `Trakt playback next up seeds require TV percent threshold`() { + val belowSeedThreshold = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + progressPercent = 94f, + source = WatchProgressSourceTraktPlayback, + ) + val seed = belowSeedThreshold.copy(progressPercent = 95f) + + assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching()) + assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching()) + } + + @Test + fun `Trakt history is not treated as active resume`() { + val history = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastPositionMs = 1L, + durationMs = 0L, + progressPercent = 50f, + source = WatchProgressSourceTraktHistory, + ) + + assertFalse(history.shouldTreatAsInProgressForContinueWatching()) + } + @Test fun `codec normalizes completed entries inferred from percent`() { val payload = WatchProgressCodec.encodeEntries( @@ -174,6 +229,7 @@ class WatchProgressRulesTest { durationMs: Long = 1_000_000L, isCompleted: Boolean = false, progressPercent: Float? = null, + source: String = WatchProgressSourceLocal, ): WatchProgressEntry = WatchProgressEntry( contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", @@ -188,5 +244,6 @@ class WatchProgressRulesTest { lastUpdatedEpochMs = lastUpdatedEpochMs, isCompleted = isCompleted, progressPercent = progressPercent, + source = source, ) } From e2bf6332b2020e0e8c03206353e76f6e897c1856 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 16:01:27 +0530 Subject: [PATCH 07/18] feat: hide unreleased content --- .../composeResources/values/strings.xml | 2 + .../app/features/catalog/CatalogRepository.kt | 11 ++- .../app/features/catalog/CatalogScreen.kt | 6 +- .../collection/FolderDetailRepository.kt | 12 +++- .../features/details/MetaDetailsRepository.kt | 22 ++++-- .../app/features/home/HomeCatalogParser.kt | 1 + .../home/HomeCatalogSettingsRepository.kt | 62 +++++++++++----- .../home/HomeCatalogSettingsSyncService.kt | 6 +- .../nuvio/app/features/home/HomeRepository.kt | 8 ++- .../app/features/home/ReleaseInfoUtils.kt | 51 +++++++++++++ .../app/features/search/SearchRepository.kt | 27 ++++++- .../nuvio/app/features/search/SearchScreen.kt | 6 +- .../settings/HomescreenSettingsPage.kt | 11 +++ .../settings/SettingsFullScreenPages.kt | 6 +- .../app/features/settings/SettingsScreen.kt | 7 ++ .../app/features/tmdb/TmdbMetadataService.kt | 2 + .../features/home/HomeCatalogParserTest.kt | 22 ++++++ .../app/features/home/ReleaseInfoUtilsTest.kt | 72 +++++++++++++++++++ 18 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6fedd1f7..6f851f18 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -475,6 +475,8 @@ %1$d of %2$d selected Show Hero Section Display hero carousel at top of home. + Hide Unreleased Content + Hide movies and shows that haven't been released yet. %1$d of %2$d catalogs visible • %3$d hero sources selected Open a catalog only when you need to rename or reorder it. Visible diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index a46ddcbf..4af61b57 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toMetaPreview +import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.features.home.filterReleasedItems +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -124,7 +127,7 @@ object CatalogRepository { catalogId = request.catalogId, genre = request.genre, skip = requestedSkip.takeIf { it > 0 }, - ) + ).withUnreleasedFilter() }.fold( onSuccess = { page -> if (activeRequest != request) return@fold @@ -158,6 +161,12 @@ object CatalogRepository { } } +private fun CatalogPage.withUnreleasedFilter(): CatalogPage { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + private data class CatalogRequest( val manifestUrl: String, val type: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index fdff2ecd..f58cd2df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -52,6 +52,7 @@ import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey import kotlinx.coroutines.flow.distinctUntilChanged @@ -74,20 +75,21 @@ fun CatalogScreen( modifier: Modifier = Modifier, ) { val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val posterCardStyle = rememberPosterCardStyleUiState() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } - LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) { + LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) { CatalogRepository.load( manifestUrl = manifestUrl, type = type, catalogId = catalogId, genre = genre, supportsPagination = supportsPagination, - force = false, + force = true, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 65c0101e..d5c7a172 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -3,14 +3,18 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE +import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.core.i18n.localizedMediaTypeLabel +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.filterReleasedItems import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.trakt.TraktPublicListSourceResolver +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -320,7 +324,7 @@ object FolderDetailRepository { genre = currentTab.genre, skip = requestedSkip.takeIf { it > 0 }, ) - } + }.withUnreleasedFilter() }.onSuccess { page -> updateTab(index) { tab -> val mergedItems = if (reset) { @@ -418,6 +422,12 @@ object FolderDetailRepository { private fun Boolean?.orFalse(): Boolean = this == true +private fun CatalogPage.withUnreleasedFilter(): CatalogPage { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + private fun tmdbCatalogId(source: CollectionSource): String = buildString { append("tmdb_") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 12e42ded..06673586 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -5,11 +5,14 @@ import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.features.home.filterReleasedItems import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,14 +51,14 @@ object MetaDetailsRepository { cachedEntry.metaScreenMeta ?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint } ?.let { cachedMeta -> - _uiState.value = MetaDetailsUiState(meta = cachedMeta) + _uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter()) activeRequestKey = requestKey return } val cachedBaseMeta = cachedEntry.baseMeta if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { - _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta) + _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter()) activeRequestKey = requestKey return } @@ -81,7 +84,7 @@ object MetaDetailsRepository { settingsFingerprint = metaScreenSettingsFingerprint, ) } - _uiState.value = MetaDetailsUiState(meta = enrichedMeta) + _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter()) activeRequestKey = requestKey } return @@ -302,7 +305,7 @@ object MetaDetailsRepository { cachedMetaByRequestKey[requestKey] = cachedEntry if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { - _uiState.value = MetaDetailsUiState(meta = meta) + _uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter()) activeRequestKey = requestKey return } @@ -324,7 +327,7 @@ object MetaDetailsRepository { metaScreenMeta = enrichedMeta, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) - _uiState.value = MetaDetailsUiState(meta = enrichedMeta) + _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter()) activeRequestKey = requestKey } @@ -374,6 +377,15 @@ object MetaDetailsRepository { return "${settings.enabled}:${settings.apiKey.trim()}:$providers" } + private fun MetaDetails.withUnreleasedFilter(): MetaDetails { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val todayIsoDate = CurrentDateProvider.todayIsoDate() + return copy( + moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate), + collectionItems = collectionItems.filterReleasedItems(todayIsoDate), + ) + } + fun findEmbeddedStreams(videoId: String): List { val meta = _uiState.value.meta ?: return emptyList() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 7efbf059..611b9109 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -52,6 +52,7 @@ internal object HomeCatalogParser { posterShape = meta.string("posterShape").toPosterShape(), description = meta.string("description"), releaseInfo = meta.string("releaseInfo"), + rawReleaseDate = meta.string("released"), imdbRating = meta.string("imdbRating"), genres = meta.array("genres").mapNotNull { genre -> genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 96b5ba6a..e920de04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem( data class HomeCatalogSettingsUiState( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) { val signature: String get() = buildString { append(heroEnabled) append('|') + append(hideUnreleasedContent) + append('|') append( items.joinToString(separator = "|") { item -> "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" @@ -55,6 +58,7 @@ internal data class HomeCatalogPreference( internal data class HomeCatalogSettingsSnapshot( val heroEnabled: Boolean, + val hideUnreleasedContent: Boolean, val preferences: Map, ) @@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference( @Serializable private data class StoredHomeCatalogSettingsPayload( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) @@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository { private var collectionDefinitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() private var heroEnabled = true + private var hideUnreleasedContent = false fun onProfileChanged() { hasLoaded = false preferences.clear() heroEnabled = true + hideUnreleasedContent = false definitions = emptyList() collectionDefinitions = emptyList() _uiState.value = HomeCatalogSettingsUiState() @@ -105,6 +112,7 @@ object HomeCatalogSettingsRepository { collectionDefinitions = emptyList() preferences.clear() heroEnabled = true + hideUnreleasedContent = false _uiState.value = HomeCatalogSettingsUiState() } @@ -135,6 +143,7 @@ object HomeCatalogSettingsRepository { ensureLoaded() return HomeCatalogSettingsSnapshot( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, preferences = preferences.mapValues { (_, value) -> HomeCatalogPreference( customTitle = value.customTitle, @@ -154,6 +163,15 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun setHideUnreleasedContent(enabled: Boolean) { + ensureLoaded() + if (hideUnreleasedContent == enabled) return + hideUnreleasedContent = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + } + fun setHeroSourceEnabled(key: String, enabled: Boolean) { updatePreference(key) { preference -> if (!enabled) { @@ -181,6 +199,7 @@ object HomeCatalogSettingsRepository { fun resetToDefaults() { ensureLoaded() heroEnabled = true + hideUnreleasedContent = false preferences.clear() normalizePreferences() publish() @@ -226,7 +245,9 @@ object HomeCatalogSettingsRepository { if (parsedPayload != null) { heroEnabled = parsedPayload.heroEnabled + hideUnreleasedContent = parsedPayload.hideUnreleasedContent preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() + publish() return } @@ -235,6 +256,7 @@ object HomeCatalogSettingsRepository { }.getOrDefault(emptyList()) preferences = legacyItems.associateBy { it.key }.toMutableMap() + publish() } private fun normalizePreferences() { @@ -322,6 +344,7 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, items = items, ) } @@ -331,6 +354,7 @@ object HomeCatalogSettingsRepository { json.encodeToString( StoredHomeCatalogSettingsPayload( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, items = preferences.values.sortedBy { it.order }, ), ), @@ -411,26 +435,32 @@ object HomeCatalogSettingsRepository { ) } } - return SyncHomeCatalogPayload(items = items) + return SyncHomeCatalogPayload( + hideUnreleasedContent = hideUnreleasedContent, + items = items, + ) } fun applyFromRemote(payload: SyncHomeCatalogPayload) { ensureLoaded() - val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } - preferences = payload.items.associate { item -> - val key = if (item.isCollection) { - "collection_${item.collectionId}" - } else { - "${item.addonId}:${item.type}:${item.catalogId}" - } - key to StoredHomeCatalogPreference( - key = key, - customTitle = item.customTitle, - enabled = item.enabled, - heroSourceEnabled = existingHeroState[key] ?: true, - order = item.order, - ) - }.toMutableMap() + hideUnreleasedContent = payload.hideUnreleasedContent + if (payload.items.isNotEmpty()) { + val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } + preferences = payload.items.associate { item -> + val key = if (item.isCollection) { + "collection_${item.collectionId}" + } else { + "${item.addonId}:${item.type}:${item.catalogId}" + } + key to StoredHomeCatalogPreference( + key = key, + customTitle = item.customTitle, + enabled = item.enabled, + heroSourceEnabled = existingHeroState[key] ?: true, + order = item.order, + ) + }.toMutableMap() + } hasLoaded = true publish() persist() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index 86f30f31..5fbf8f7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -41,6 +41,7 @@ data class SyncCatalogItem( @Serializable data class SyncHomeCatalogPayload( + @SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) @@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService { } if (remotePayload.items.isEmpty()) { - log.i { "pullFromServer — remote has empty items, preserving local" } + log.i { "pullFromServer — remote has empty items, preserving local catalog order" } + isSyncingFromRemote = true + HomeCatalogSettingsRepository.applyFromRemote(remotePayload) + isSyncingFromRemote = false val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() if (localPayload.items.isNotEmpty()) { pushToRemote(profileId) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 0e24e109..4573db3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.catalog.fetchCatalogPage +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -145,13 +146,17 @@ object HomeRepository { ) { val snapshot = HomeCatalogSettingsRepository.snapshot() val preferences = snapshot.preferences + val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null + fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection = + if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate) + val sections = currentDefinitions .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } .mapNotNull { definition -> val preference = preferences[definition.key] if (preference?.enabled == false) return@mapNotNull null - val section = cachedSections[definition.key] ?: return@mapNotNull null + val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null if (section.items.isEmpty()) return@mapNotNull null val customTitle = preference?.customTitle.orEmpty() section.copy( @@ -164,6 +169,7 @@ object HomeRepository { currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } .mapNotNull { definition -> cachedSections[definition.key] } + .map { section -> section.withReleaseFilter() } .flatMap { section -> section.items } .distinctBy { item -> "${item.type}:${item.id}" } .shuffled(heroRandom) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt new file mode 100644 index 00000000..f7b3bf41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.home + +private val yearRegex = Regex("""\b(19|20)\d{2}\b""") +private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""") + +internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { + rawReleaseDate + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { rawReleased -> + isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate -> + return releaseDate > todayIsoDate + } + } + + val info = releaseInfo ?: return false + isoCalendarDateOrNull(info.trim())?.let { releaseDate -> + return releaseDate > todayIsoDate + } + + val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false + val currentYear = todayIsoDate.take(4).toIntOrNull() ?: return false + return releaseYear > currentYear +} + +internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection { + val filteredItems = items.filterReleasedItems(todayIsoDate) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + +internal fun List.filterReleasedItems(todayIsoDate: String): List = + filterNot { item -> item.isUnreleased(todayIsoDate) } + +private fun isoCalendarDateOrNull(value: String?): String? { + val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null + val year = date.substring(0, 4).toIntOrNull() ?: return null + val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = date.substring(8, 10).toIntOrNull() ?: return null + if (day !in 1..daysInMonth(year, month)) return null + return date +} + +private fun daysInMonth(year: Int, month: Int): Int = + when (month) { + 2 -> if (isLeapYear(year)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + +private fun isLeapYear(year: Int): Boolean = + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index 6579e0db..b71d97a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -5,12 +5,16 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.buildCatalogUrl import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.supportsPagination +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.filterReleasedItems +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,6 +41,7 @@ object SearchRepository { private var activeDiscoverJob: Job? = null private var lastRequestKey: String? = null private var discoverSources: List = emptyList() + private var lastDiscoverHideUnreleasedContent: Boolean? = null fun search(query: String, addons: List) { val normalizedQuery = query.trim() @@ -71,6 +76,8 @@ object SearchRepository { val requestKey = buildString { append(normalizedQuery.lowercase()) append('|') + append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) + append('|') append( requests.joinToString(separator = "|") { request -> "${request.addon.manifestUrl}:${request.type}:${request.catalogId}" @@ -119,6 +126,7 @@ object SearchRepository { activeDiscoverJob?.cancel() lastRequestKey = null discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null _uiState.value = SearchUiState() _discoverUiState.value = DiscoverUiState() } @@ -128,6 +136,7 @@ object SearchRepository { if (activeAddons.isEmpty()) { activeDiscoverJob?.cancel() discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null log.d { "Discover refresh aborted: no active addons" } _discoverUiState.value = DiscoverUiState( emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons, @@ -137,7 +146,12 @@ object SearchRepository { val sources = buildDiscoverSources(activeAddons) val current = _discoverUiState.value - if (sources == discoverSources && current.canReuseDiscoverState(sources)) { + val hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent + if ( + sources == discoverSources && + lastDiscoverHideUnreleasedContent == hideUnreleasedContent && + current.canReuseDiscoverState(sources) + ) { log.d { "Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " + "genre=${current.selectedGenre ?: ""} items=${current.items.size} nextSkip=${current.nextSkip}" @@ -146,6 +160,7 @@ object SearchRepository { } discoverSources = sources + lastDiscoverHideUnreleasedContent = hideUnreleasedContent if (sources.isEmpty()) { activeDiscoverJob?.cancel() log.d { "Discover refresh found no compatible discover catalogs" } @@ -310,7 +325,7 @@ object SearchRepository { type = type, catalogId = catalogId, search = query, - ) + ).withUnreleasedFilter() val items = page.items require(items.isNotEmpty()) { "No search results returned for $catalogName." } @@ -364,7 +379,7 @@ object SearchRepository { catalogId = selectedCatalog.catalogId, genre = current.selectedGenre, skip = requestedSkip.takeIf { it > 0 }, - ) + ).withUnreleasedFilter() }.fold( onSuccess = { page -> val latest = _discoverUiState.value @@ -421,6 +436,12 @@ object SearchRepository { } } +private fun CatalogPage.withUnreleasedFilter(): CatalogPage { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + private data class SearchCatalogRequest( val addon: ManagedAddon, val catalogId: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 45e335eb..c25a67fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -46,6 +46,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -88,6 +89,7 @@ fun SearchScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -123,11 +125,11 @@ fun SearchScreen( } } - LaunchedEffect(addonRefreshKey) { + LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) { SearchRepository.refreshDiscover(addonsUiState.addons) } - LaunchedEffect(query, addonRefreshKey) { + LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) { val normalizedQuery = query.trim() if (normalizedQuery.isBlank()) { lastRequestedQuery = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index adaea670..ee44ba7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -38,6 +38,8 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.layout_hide_unreleased +import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub import nuvio.composeapp.generated.resources.settings_homescreen_empty_message import nuvio.composeapp.generated.resources.settings_homescreen_empty_title import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused @@ -62,6 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, heroEnabled: Boolean, + hideUnreleasedContent: Boolean, items: List, ) { val selectedHeroSourceCount = items.count { it.heroSourceEnabled } @@ -87,6 +90,14 @@ internal fun LazyListScope.homescreenSettingsContent( isTablet = isTablet, onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.layout_hide_unreleased), + description = stringResource(Res.string.layout_hide_unreleased_sub), + checked = hideUnreleasedContent, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index b8cd870d..143ef517 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -45,7 +45,10 @@ fun HomescreenSettingsScreen( } } } - val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -74,6 +77,7 @@ fun HomescreenSettingsScreen( homescreenSettingsContent( isTablet = false, heroEnabled = homescreenSettingsUiState.heroEnabled, + hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, items = homescreenSettingsUiState.items, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 9818b247..dd9ae84b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -135,6 +135,7 @@ fun SettingsScreen( } } val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val metaScreenSettingsUiState by remember { @@ -199,6 +200,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -240,6 +242,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -291,6 +294,7 @@ private fun MobileSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -399,6 +403,7 @@ private fun MobileSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = false, heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, items = homescreenItems, ) SettingsPage.MetaScreen -> metaScreenSettingsContent( @@ -461,6 +466,7 @@ private fun TabletSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -640,6 +646,7 @@ private fun TabletSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, items = homescreenItems, ) SettingsPage.MetaScreen -> metaScreenSettingsContent( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index 823125a6..cc87a1e5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -1052,6 +1052,7 @@ object TmdbMetadataService { posterShape = PosterShape.Poster, description = recommendation.overview?.trim()?.takeIf(String::isNotBlank), releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4), + rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate, imdbRating = recommendation.voteAverage?.formatRating(), ) } @@ -1087,6 +1088,7 @@ object TmdbMetadataService { posterShape = PosterShape.Landscape, description = part.overview?.trim()?.takeIf(String::isNotBlank), releaseInfo = part.releaseDate?.take(4), + rawReleaseDate = part.releaseDate, imdbRating = part.voteAverage?.formatRating(), ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt index d44a3b82..65c94d8a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt @@ -49,4 +49,26 @@ class HomeCatalogParserTest { result.items.map { it.stableKey() }, ) } + + @Test + fun `parse catalog response keeps raw released date for unreleased filtering`() { + val result = HomeCatalogParser.parseCatalogResponse( + payload = """ + { + "metas": [ + { + "id": "tt1", + "type": "movie", + "name": "Future Movie", + "releaseInfo": "2027", + "released": "2027-05-12T00:00:00.000Z" + } + ] + } + """.trimIndent(), + ) + + assertEquals("2027", result.items.single().releaseInfo) + assertEquals("2027-05-12T00:00:00.000Z", result.items.single().rawReleaseDate) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt new file mode 100644 index 00000000..dc00ef0b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReleaseInfoUtilsTest { + + @Test + fun `raw released date after today is unreleased`() { + val item = preview(rawReleaseDate = "2026-06-15T00:00:00.000Z", releaseInfo = "2026") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `release info full date after today is unreleased`() { + val item = preview(rawReleaseDate = null, releaseInfo = "2026-06-15") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `future release info year is unreleased`() { + val item = preview(rawReleaseDate = null, releaseInfo = "Coming in 2027") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `released and unknown dates are kept`() { + assertFalse(preview(rawReleaseDate = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06")) + } + + @Test + fun `catalog section filters unreleased items`() { + val section = HomeCatalogSection( + key = "addon:movie:popular", + title = "Popular", + subtitle = "Addon", + addonName = "Addon", + type = "movie", + manifestUrl = "https://example.com/manifest.json", + catalogId = "popular", + items = listOf( + preview(id = "released", rawReleaseDate = "2026-05-01", releaseInfo = "2026"), + preview(id = "future", rawReleaseDate = "2026-07-01", releaseInfo = "2026"), + ), + availableItemCount = 2, + ) + + val result = section.filterReleasedItems(todayIsoDate = "2026-05-06") + + assertEquals(listOf("released"), result.items.map { it.id }) + assertEquals(2, result.availableItemCount) + } + + private fun preview( + id: String = "tt1", + rawReleaseDate: String?, + releaseInfo: String?, + ): MetaPreview = MetaPreview( + id = id, + type = "movie", + name = id, + rawReleaseDate = rawReleaseDate, + releaseInfo = releaseInfo, + ) +} From 3f4a0f6a02de5e44a2eeacc853daf251c77ba251 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 16:02:42 +0530 Subject: [PATCH 08/18] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 0a0e7524..a772ced9 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=53 -MARKETING_VERSION=0.1.14 +CURRENT_PROJECT_VERSION=54 +MARKETING_VERSION=0.1.15 From b3ed47732a74252004bc28773ff878174d255d99 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 18:16:27 +0530 Subject: [PATCH 09/18] refactor: update continue watching thumbnail preference logic --- .../src/commonMain/composeResources/values/strings.xml | 4 ++-- .../home/components/HomeContinueWatchingSection.kt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6f851f18..d69fdba4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -524,8 +524,8 @@ Info-dense horizontal card Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead. Up Next From Furthest Episode - Use episode thumbnails as default image. When disabled, uses backdrop. - Use Episode Thumbnails in Continue Watching + Prefer episode thumbnails when available. + Prefer Episode Thumbnails in Continue Watching HOME SOURCES Install, remove, refresh, and sort your content sources. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index c3f44947..9bf4d92a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -56,25 +56,25 @@ private fun ContinueWatchingItem.continueWatchingArtworkUrl( ): String? = when { isNextUp && useEpisodeThumbnails -> firstNonBlank( episodeThumbnail, - background, poster, + background, imageUrl, ) isNextUp -> firstNonBlank( - background, poster, + background, episodeThumbnail, imageUrl, ) useEpisodeThumbnails -> firstNonBlank( episodeThumbnail, - background, poster, + background, imageUrl, ) else -> firstNonBlank( - background, poster, + background, episodeThumbnail, imageUrl, ) From 8b2a635174447b754f7e8f0526ced22ff8820a7a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 19:20:32 +0530 Subject: [PATCH 10/18] feat(ios): ios native navbar --- .../app/core/ui/NativeTabBridge.android.kt | 9 + .../settings/ThemeSettingsStorage.android.kt | 22 ++- .../composeResources/values/strings.xml | 2 + .../commonMain/kotlin/com/nuvio/app/App.kt | 151 ++++++++++----- .../app/core/sync/ProfileSettingsSync.kt | 2 + .../com/nuvio/app/core/ui/NativeTabBridge.kt | 51 +++++ .../nuvio/app/core/ui/NuvioPlatformInsets.kt | 7 +- .../com/nuvio/app/features/home/HomeScreen.kt | 14 +- .../settings/AppearanceSettingsPage.kt | 15 ++ .../app/features/settings/SettingsScreen.kt | 27 ++- .../settings/ThemeSettingsRepository.kt | 17 ++ .../features/settings/ThemeSettingsStorage.kt | 2 + .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 38 ++++ .../settings/ThemeSettingsStorage.ios.kt | 26 ++- iosApp/Configuration/Version.xcconfig | 2 +- iosApp/iosApp/ContentView.swift | 181 +++++++++++++++++- 16 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt new file mode 100644 index 00000000..a638c7fa --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.core.ui + +internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false + +internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit + +internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit + +internal actual fun publishNativeSelectedTab(tabName: String) = Unit diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index 8b1506f0..e082a536 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -17,8 +17,13 @@ actual object ThemeSettingsStorage { private const val preferencesName = "nuvio_theme_settings" private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" + private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" - private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey) + private val profileScopedSyncKeys = listOf( + selectedThemeKey, + amoledEnabledKey, + liquidGlassNativeTabBarEnabledKey, + ) private val globalSyncKeys = listOf(selectedAppLanguageKey) private var preferences: SharedPreferences? = null @@ -51,6 +56,19 @@ actual object ThemeSettingsStorage { ?.apply() } + actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? = + preferences?.let { prefs -> + val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey) + if (prefs.contains(key)) prefs.getBoolean(key, false) else null + } + + actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled) + ?.apply() + } + actual fun loadSelectedAppLanguage(): String? { val value = preferences?.getString(selectedAppLanguageKey, null) if (value != null) return value @@ -75,6 +93,7 @@ actual object ThemeSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } @@ -86,6 +105,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d69fdba4..42d4c850 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -449,6 +449,8 @@ App Language Choose Language Settings for the Continue Watching section. + Liquid Glass + Use the native iPhone tab bar on iOS 26 and later. Tune card width and corner radius. DISPLAY HOME diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index eea60cd6..ab33b6d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -92,6 +93,10 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme +import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding +import com.nuvio.app.core.ui.NativeNavigationTab +import com.nuvio.app.core.ui.NativeTabBridge +import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository @@ -260,6 +265,20 @@ enum class AppScreenTab { Settings, } +private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { + AppScreenTab.Home -> NativeNavigationTab.Home + AppScreenTab.Search -> NativeNavigationTab.Search + AppScreenTab.Library -> NativeNavigationTab.Library + AppScreenTab.Settings -> NativeNavigationTab.Settings +} + +private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) { + NativeNavigationTab.Home -> AppScreenTab.Home + NativeNavigationTab.Search -> AppScreenTab.Search + NativeNavigationTab.Library -> AppScreenTab.Library + NativeNavigationTab.Settings -> AppScreenTab.Settings +} + private enum class AppGateScreen { Loading, Auth, @@ -466,6 +485,11 @@ private fun MainAppContent( val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarEnabled by remember { + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled + }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterForActions by remember { mutableStateOf(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } @@ -515,6 +539,16 @@ private fun MainAppContent( .sorted() } + LaunchedEffect(nativeRequestedTab) { + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + selectedTab = nativeRequestedTab.toAppScreenTab() + } + } + + LaunchedEffect(selectedTab) { + NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) + } + LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() EpisodeReleaseNotificationsRepository.refreshAsync() @@ -886,6 +920,8 @@ private fun MainAppContent( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val isTabletLayout = maxWidth >= 768.dp + val useNativeBottomTabs = + liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home @@ -893,6 +929,13 @@ private fun MainAppContent( com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } + DisposableEffect(useNativeBottomTabs) { + NativeTabBridge.publishTabBarVisible(useNativeBottomTabs) + onDispose { + NativeTabBridge.publishTabBarVisible(false) + } + } + Scaffold( modifier = Modifier .fillMaxSize() @@ -900,7 +943,7 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout) { + if (!isTabletLayout && !useNativeBottomTabs) { NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, @@ -936,58 +979,62 @@ private fun MainAppContent( }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { - AppTabHost( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - selectedTab = selectedTab, - onCatalogClick = onCatalogClick, - onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) - }, - onPosterLongClick = { meta -> - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterForActions = meta - }, - onLibraryPosterClick = { item -> - navController.navigate(DetailRoute(type = item.type, id = item.id)) - }, - onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, - onContinueWatchingClick = onContinueWatchingClick, - onContinueWatchingLongPress = onContinueWatchingLongPress, - onSwitchProfile = onSwitchProfile, - onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, - onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, - onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, - onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, - onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, - onPluginsSettingsClick = { - if (AppFeaturePolicy.pluginsEnabled) { - navController.navigate(PluginsSettingsRoute) - } - }, - onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, - onSupportersContributorsSettingsClick = { - navController.navigate(SupportersContributorsSettingsRoute) - }, - onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { - { - appUpdaterController.checkForUpdates( - force = true, - showNoUpdateFeedback = true, - ) - } - } else { - null - }, - onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, - onFolderClick = { collectionId, folderId -> - navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) - }, - onInitialHomeContentRendered = { initialHomeReady = true }, - ) + CompositionLocalProvider( + LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp, + ) { + AppTabHost( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + selectedTab = selectedTab, + onCatalogClick = onCatalogClick, + onPosterClick = { meta -> + navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + }, + onPosterLongClick = { meta -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + selectedPosterForActions = meta + }, + onLibraryPosterClick = { item -> + navController.navigate(DetailRoute(type = item.type, id = item.id)) + }, + onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, + onContinueWatchingClick = onContinueWatchingClick, + onContinueWatchingLongPress = onContinueWatchingLongPress, + onSwitchProfile = onSwitchProfile, + onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, + onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, + onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, + onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, + onPluginsSettingsClick = { + if (AppFeaturePolicy.pluginsEnabled) { + navController.navigate(PluginsSettingsRoute) + } + }, + onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, + onSupportersContributorsSettingsClick = { + navController.navigate(SupportersContributorsSettingsRoute) + }, + onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { + { + appUpdaterController.checkForUpdates( + force = true, + showNoUpdateFeedback = true, + ) + } + } else { + null + }, + onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, + onFolderClick = { collectionId, folderId -> + navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) + }, + onInitialHomeContentRendered = { initialHomeReady = true }, + ) + } - if (isTabletLayout) { + if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index cdbd477a..9dd7a999 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -152,6 +152,7 @@ object ProfileSettingsSync { val signatureFlows = listOf( ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" }, + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, TmdbSettingsRepository.uiState.map { "tmdb" }, @@ -265,6 +266,7 @@ object ProfileSettingsSync { private fun currentObservedStateSignature(): String = listOf( "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", + "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt new file mode 100644 index 00000000..b9eefda2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.core.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal enum class NativeNavigationTab { + Home, + Search, + Library, + Settings, + ; + + companion object { + fun fromName(name: String): NativeNavigationTab = + entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Home + } +} + +internal object NativeTabBridge { + private val _requestedTab = MutableStateFlow(NativeNavigationTab.Home) + val requestedTab: StateFlow = _requestedTab.asStateFlow() + + fun requestTab(tabName: String) { + _requestedTab.value = NativeNavigationTab.fromName(tabName) + } + + fun publishSelectedTab(tab: NativeNavigationTab) { + publishNativeSelectedTab(tab.name) + } + + fun publishTabBarVisible(visible: Boolean) { + publishNativeTabBarVisible(visible && isLiquidGlassNativeTabBarSupported()) + } + + fun publishLiquidGlassEnabled(enabled: Boolean) { + publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported()) + } +} + +fun nativeTabSelect(tabName: String) { + NativeTabBridge.requestTab(tabName) +} + +internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean + +internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) + +internal expect fun publishNativeTabBarVisible(visible: Boolean) + +internal expect fun publishNativeSelectedTab(tabName: String) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt index b6ea9a37..2fc73a4f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt @@ -3,6 +3,7 @@ package com.nuvio.app.core.ui import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp @Composable internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets +internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } + @Composable internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { val navigationBarBottom = nuvioBottomNavigationBarInsets() .asPaddingValues() .calculateBottomPadding() - return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra + return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + + LocalNuvioBottomNavigationOverlayPadding.current + + extra } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index b7fa9134..87879839 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard +import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.nextReleasedEpisodeAfter @@ -405,12 +407,19 @@ fun HomeScreen( BoxWithConstraints(modifier = modifier.fillMaxSize()) { val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value) val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value) + val nativeBottomNavigationOverlayHeight = + if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) { + nuvioSafeBottomPadding() + } else { + 0.dp + } val mobileHeroBelowSectionHeightHint = remember( maxWidth.value, continueWatchingPreferences.isVisible, continueWatchingPreferences.style, continueWatchingItems.isNotEmpty(), continueWatchingLayout, + nativeBottomNavigationOverlayHeight, ) { heroMobileBelowSectionHeightHint( maxWidthDp = maxWidth.value, @@ -418,6 +427,7 @@ fun HomeScreen( hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), continueWatchingStyle = continueWatchingPreferences.style, continueWatchingLayout = continueWatchingLayout, + bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight, ) } @@ -605,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint( hasContinueWatchingItems: Boolean, continueWatchingStyle: ContinueWatchingSectionStyle, continueWatchingLayout: ContinueWatchingLayout, + bottomNavigationOverlayHeight: Dp, ): Dp? { if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null - return when (continueWatchingStyle) { + val sectionHeight = when (continueWatchingStyle) { ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp ContinueWatchingSectionStyle.Poster -> continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp } + return sectionHeight + bottomNavigationOverlayHeight } internal fun buildHomeContinueWatchingItems( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index f697b48d..bc312982 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she import nuvio.composeapp.generated.resources.settings_appearance_amoled_black import nuvio.composeapp.generated.resources.settings_appearance_amoled_description import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description +import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass +import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description import nuvio.composeapp.generated.resources.settings_appearance_section_display import nuvio.composeapp.generated.resources.settings_appearance_section_home @@ -70,6 +72,9 @@ internal fun LazyListScope.appearanceSettingsContent( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + liquidGlassNativeTabBarSupported: Boolean, + liquidGlassNativeTabBarEnabled: Boolean, + onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, onContinueWatchingClick: () -> Unit, @@ -118,6 +123,16 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, onCheckedChange = onAmoledToggle, ) + if (liquidGlassNativeTabBarSupported) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_appearance_liquid_glass), + description = stringResource(Res.string.settings_appearance_liquid_glass_description), + checked = liquidGlassNativeTabBarEnabled, + isTablet = isTablet, + onCheckedChange = onLiquidGlassNativeTabBarToggle, + ) + } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_appearance_app_language), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index dd9ae84b..b625c9dc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -38,9 +38,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.PlatformBackHandler +import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsUiState @@ -94,6 +96,10 @@ fun SettingsScreen( ThemeSettingsRepository.selectedTheme }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarEnabled by remember { + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled + }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() @@ -191,6 +197,9 @@ fun SettingsScreen( onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, @@ -233,6 +242,9 @@ fun SettingsScreen( onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, @@ -285,6 +297,9 @@ private fun MobileSettingsScreen( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + liquidGlassNativeTabBarSupported: Boolean, + liquidGlassNativeTabBarEnabled: Boolean, + onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, @@ -366,6 +381,9 @@ private fun MobileSettingsScreen( onThemeSelected = onThemeSelected, amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, onContinueWatchingClick = onContinueWatchingClick, @@ -457,6 +475,9 @@ private fun TabletSettingsScreen( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + liquidGlassNativeTabBarSupported: Boolean, + liquidGlassNativeTabBarEnabled: Boolean, + onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, @@ -539,6 +560,7 @@ private fun TabletSettingsScreen( saveableStateHolder.SaveableStateProvider(page.name) { val listState = rememberLazyListState() + val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -546,7 +568,7 @@ private fun TabletSettingsScreen( start = 40.dp, top = topOffset, end = 40.dp, - bottom = 40.dp, + bottom = 40.dp + bottomOverlayPadding, ), verticalArrangement = Arrangement.spacedBy(18.dp), ) { @@ -609,6 +631,9 @@ private fun TabletSettingsScreen( onThemeSelected = onThemeSelected, amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 863dd04f..41d53fc6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.settings import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.NativeTabBridge import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,6 +13,9 @@ object ThemeSettingsRepository { private val _amoledEnabled = MutableStateFlow(false) val amoledEnabled: StateFlow = _amoledEnabled.asStateFlow() + private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false) + val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow() + private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() @@ -30,6 +34,8 @@ object ThemeSettingsRepository { hasLoaded = false _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false + _liquidGlassNativeTabBarEnabled.value = false + NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.ENGLISH } @@ -47,6 +53,9 @@ object ThemeSettingsRepository { } _selectedTheme.value = theme _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false + val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false + _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled + NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled) val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage @@ -66,6 +75,14 @@ object ThemeSettingsRepository { ThemeSettingsStorage.saveAmoledEnabled(enabled) } + fun setLiquidGlassNativeTabBar(enabled: Boolean) { + ensureLoaded() + if (_liquidGlassNativeTabBarEnabled.value == enabled) return + _liquidGlassNativeTabBarEnabled.value = enabled + ThemeSettingsStorage.saveLiquidGlassNativeTabBarEnabled(enabled) + NativeTabBridge.publishLiquidGlassEnabled(enabled) + } + fun setAppLanguage(language: AppLanguage) { ensureLoaded() if (_selectedAppLanguage.value == language) return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index dc39dee5..2a788baf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -7,6 +7,8 @@ internal expect object ThemeSettingsStorage { fun saveSelectedTheme(themeName: String) fun loadAmoledEnabled(): Boolean? fun saveAmoledEnabled(enabled: Boolean) + fun loadLiquidGlassNativeTabBarEnabled(): Boolean? + fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt new file mode 100644 index 00000000..7e23415c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.core.ui + +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSUserDefaults +import platform.UIKit.UIDevice +import platform.UIKit.UIUserInterfaceIdiomPhone + +private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled" +private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" +private const val nativeSelectedTabKey = "NuvioNativeSelectedTab" +private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" + +internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { + return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone && + (UIDevice.currentDevice.systemVersion.substringBefore(".").toIntOrNull() ?: 0) >= 26 +} + +internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + publishBool(liquidGlassNativeTabBarEnabledKey, enabled) +} + +internal actual fun publishNativeTabBarVisible(visible: Boolean) { + publishBool(nativeTabBarVisibleKey, visible) +} + +internal actual fun publishNativeSelectedTab(tabName: String) { + NSUserDefaults.standardUserDefaults.setObject(tabName, forKey = nativeSelectedTabKey) + notifyNativeTabChromeChanged() +} + +private fun publishBool(key: String, value: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) + notifyNativeTabChromeChanged() +} + +private fun notifyNativeTabChromeChanged() { + NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index c878b4a8..f66f8b8c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -13,8 +13,13 @@ import platform.Foundation.NSUserDefaults actual object ThemeSettingsStorage { private const val selectedThemeKey = "selected_theme" private const val amoledEnabledKey = "amoled_enabled" + private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" - private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey) + private val profileScopedSyncKeys = listOf( + selectedThemeKey, + amoledEnabledKey, + liquidGlassNativeTabBarEnabledKey, + ) private val globalSyncKeys = listOf(selectedAppLanguageKey) actual fun loadSelectedTheme(): String? = @@ -38,6 +43,23 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey)) } + actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool( + enabled, + forKey = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), + ) + } + actual fun loadSelectedAppLanguage(): String? { val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) if (value != null) return value @@ -65,6 +87,7 @@ actual object ThemeSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } @@ -78,6 +101,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index a772ced9..965f9e75 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=54 -MARKETING_VERSION=0.1.15 +MARKETING_VERSION=0.1.0 diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 8b736eb9..a4268fa7 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -2,8 +2,57 @@ import UIKit import SwiftUI import ComposeApp -final class RootComposeViewController: UIViewController { +final class RootComposeViewController: UIViewController, UITabBarDelegate { + private enum NativeTab: String, CaseIterable { + case home = "Home" + case search = "Search" + case library = "Library" + case settings = "Settings" + + var tag: Int { + switch self { + case .home: return 0 + case .search: return 1 + case .library: return 2 + case .settings: return 3 + } + } + + var title: String { + switch self { + case .home: return "Home" + case .search: return "Search" + case .library: return "Library" + case .settings: return "Profile" + } + } + + var systemImageName: String { + switch self { + case .home: return "house" + case .search: return "magnifyingglass" + case .library: return "books.vertical" + case .settings: return "person.crop.circle" + } + } + + init?(tag: Int) { + guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil } + self = tab + } + } + + private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled" + private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" + private static let nativeSelectedTabKey = "NuvioNativeSelectedTab" + private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange") + private let contentController: UIViewController + private let tabBar = UITabBar() + private var contentBottomToViewBottom: NSLayoutConstraint? + private var tabBarHeightConstraint: NSLayoutConstraint? + private var userDefaultsObserver: NSObjectProtocol? + private var tabChromeObserver: NSObjectProtocol? init(contentController: UIViewController) { self.contentController = contentController @@ -20,17 +69,44 @@ final class RootComposeViewController: UIViewController { view.backgroundColor = .black contentController.view.backgroundColor = .black + UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey) addChild(contentController) view.addSubview(contentController.view) contentController.view.translatesAutoresizingMaskIntoConstraints = false + let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + self.contentBottomToViewBottom = bottomToViewBottom NSLayoutConstraint.activate([ contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentController.view.topAnchor.constraint(equalTo: view.topAnchor), - contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bottomToViewBottom, ]) contentController.didMove(toParent: self) + + configureNativeTabBar() + installNativeTabObservers() + syncNativeTabChrome(animated: false) + } + + deinit { + if let userDefaultsObserver { + NotificationCenter.default.removeObserver(userDefaultsObserver) + } + if let tabChromeObserver { + NotificationCenter.default.removeObserver(tabChromeObserver) + } + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + updateTabBarHeight() + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + guard let tab = NativeTab(tag: item.tag) else { return } + UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey) + NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue) } override var childForHomeIndicatorAutoHidden: UIViewController? { @@ -88,6 +164,107 @@ final class RootComposeViewController: UIViewController { return nil } + + private var nativeTabsSupported: Bool { + UIDevice.current.userInterfaceIdiom == .phone && + ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 + } + + private var shouldShowNativeTabBar: Bool { + nativeTabsSupported && + UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) && + UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey) + } + + private func configureNativeTabBar() { + tabBar.delegate = self + tabBar.translatesAutoresizingMaskIntoConstraints = false + tabBar.items = NativeTab.allCases.map { tab in + UITabBarItem( + title: tab.title, + image: UIImage(systemName: tab.systemImageName), + tag: tab.tag + ) + } + tabBar.selectedItem = tabBar.items?.first + tabBar.alpha = 0 + tabBar.isHidden = true + + view.addSubview(tabBar) + let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) + tabBarHeightConstraint = heightConstraint + NSLayoutConstraint.activate([ + tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), + heightConstraint, + ]) + } + + private func installNativeTabObservers() { + userDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.syncNativeTabChrome(animated: true) + } + + tabChromeObserver = NotificationCenter.default.addObserver( + forName: Self.nativeTabChromeDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.syncNativeTabChrome(animated: true) + } + } + + private var tabBarHeight: CGFloat { + 49 + view.safeAreaInsets.bottom + } + + private func updateTabBarHeight() { + tabBarHeightConstraint?.constant = tabBarHeight + } + + private func syncNativeTabChrome(animated: Bool) { + updateTabBarHeight() + syncSelectedNativeTab() + + let visible = shouldShowNativeTabBar + contentBottomToViewBottom?.isActive = true + if visible { + tabBar.isHidden = false + } + + let changes = { + self.tabBar.alpha = visible ? 1 : 0 + self.view.layoutIfNeeded() + } + + let completion: (Bool) -> Void = { _ in + self.tabBar.isHidden = !visible + } + + if animated && view.window != nil { + UIView.animate( + withDuration: 0.22, + delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut], + animations: changes, + completion: completion + ) + } else { + changes() + completion(true) + } + } + + private func syncSelectedNativeTab() { + let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue + let selectedTab = NativeTab(rawValue: rawValue) ?? .home + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag }) + } } struct ComposeView: UIViewControllerRepresentable { From 38a786850c05e9ed5d80a319864d8a475924e09e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 19:39:06 +0530 Subject: [PATCH 11/18] ref(ios): native tab to use app icons and avatars --- .../app/core/ui/NativeTabBridge.android.kt | 9 + .../commonMain/kotlin/com/nuvio/app/App.kt | 26 ++ .../com/nuvio/app/core/ui/NativeTabBridge.kt | 27 ++ .../settings/ThemeSettingsRepository.kt | 13 + .../nuvio/app/core/ui/NativeTabBridge.ios.kt | 31 ++ iosApp/iosApp/ContentView.swift | 379 +++++++++++++++++- 6 files changed, 477 insertions(+), 8 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt index a638c7fa..c7c556c5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -7,3 +7,12 @@ internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Un internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit internal actual fun publishNativeSelectedTab(tabName: String) = Unit + +internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit + +internal actual fun publishNativeProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, +) = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ab33b6d4..51020aaf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -127,11 +127,13 @@ import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders +import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab +import com.nuvio.app.features.profiles.avatarStorageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen @@ -316,9 +318,33 @@ fun App() { val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() + + LaunchedEffect( + profileState.activeProfile?.profileIndex, + profileState.activeProfile?.name, + profileState.activeProfile?.avatarColorHex, + profileState.activeProfile?.avatarId, + profileAvatars, + ) { + val activeProfile = profileState.activeProfile + val avatarItem = activeProfile?.avatarId?.let { avatarId -> + profileAvatars.find { it.id == avatarId } + } + NativeTabBridge.publishProfileTabIcon( + name = activeProfile?.name, + avatarColorHex = activeProfile?.avatarColorHex, + avatarImageUrl = avatarItem + ?.storagePath + ?.takeIf { it.isNotBlank() } + ?.let(::avatarStorageUrl), + avatarBackgroundColorHex = avatarItem?.bgColor, + ) + } + var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index b9eefda2..d7422533 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -36,6 +36,24 @@ internal object NativeTabBridge { fun publishLiquidGlassEnabled(enabled: Boolean) { publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported()) } + + fun publishAccentColor(hexColor: String) { + publishNativeTabAccentColor(hexColor) + } + + fun publishProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, + ) { + publishNativeProfileTabIcon( + name = name, + avatarColorHex = avatarColorHex, + avatarImageUrl = avatarImageUrl, + avatarBackgroundColorHex = avatarBackgroundColorHex, + ) + } } fun nativeTabSelect(tabName: String) { @@ -49,3 +67,12 @@ internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) internal expect fun publishNativeTabBarVisible(visible: Boolean) internal expect fun publishNativeSelectedTab(tabName: String) + +internal expect fun publishNativeTabAccentColor(hexColor: String) + +internal expect fun publishNativeProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 41d53fc6..2f1221dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -35,6 +35,7 @@ object ThemeSettingsRepository { _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false _liquidGlassNativeTabBarEnabled.value = false + NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.ENGLISH } @@ -52,6 +53,7 @@ object ThemeSettingsRepository { AppTheme.WHITE } _selectedTheme.value = theme + NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex()) _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled @@ -66,6 +68,7 @@ object ThemeSettingsRepository { if (_selectedTheme.value == theme) return _selectedTheme.value = theme ThemeSettingsStorage.saveSelectedTheme(theme.name) + NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex()) } fun setAmoled(enabled: Boolean) { @@ -91,3 +94,13 @@ object ThemeSettingsRepository { _selectedAppLanguage.value = language } } + +private fun AppTheme.nativeTabAccentHex(): String = when (this) { + AppTheme.CRIMSON -> "#E53935" + AppTheme.OCEAN -> "#1E88E5" + AppTheme.VIOLET -> "#8E24AA" + AppTheme.EMERALD -> "#43A047" + AppTheme.AMBER -> "#FB8C00" + AppTheme.ROSE -> "#D81B60" + AppTheme.WHITE -> "#F5F5F5" +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt index 7e23415c..1b72da7c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -8,6 +8,11 @@ import platform.UIKit.UIUserInterfaceIdiomPhone private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled" private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" private const val nativeSelectedTabKey = "NuvioNativeSelectedTab" +private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor" +private const val nativeProfileNameKey = "NuvioNativeProfileName" +private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" +private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" +private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { @@ -28,11 +33,37 @@ internal actual fun publishNativeSelectedTab(tabName: String) { notifyNativeTabChromeChanged() } +internal actual fun publishNativeTabAccentColor(hexColor: String) { + NSUserDefaults.standardUserDefaults.setObject(hexColor, forKey = nativeTabAccentColorKey) + notifyNativeTabChromeChanged() +} + +internal actual fun publishNativeProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, +) { + publishString(nativeProfileNameKey, name) + publishString(nativeProfileAvatarColorKey, avatarColorHex) + publishString(nativeProfileAvatarUrlKey, avatarImageUrl) + publishString(nativeProfileAvatarBackgroundColorKey, avatarBackgroundColorHex) + notifyNativeTabChromeChanged() +} + private fun publishBool(key: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) notifyNativeTabChromeChanged() } +private fun publishString(key: String, value: String?) { + if (value.isNullOrBlank()) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(key) + } else { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = key) + } +} + private fun notifyNativeTabChromeChanged() { NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null) } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index a4268fa7..14f5664a 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -2,6 +2,257 @@ import UIKit import SwiftUI import ComposeApp +private enum NuvioNativeTabIcon { + static let home = vectorIcon( + viewport: CGSize(width: 24, height: 24), + paths: [ + "M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z", + ] + ) + + static let search = drawnIcon { context, rect in + drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) { + context.setStrokeColor(UIColor.black.cgColor) + context.setLineWidth(2) + context.setLineCap(.round) + context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12)) + context.move(to: CGPoint(x: 13.6, y: 13.6)) + context.addLine(to: CGPoint(x: 17, y: 17)) + context.strokePath() + } + } + + static let library = vectorIcon( + viewport: CGSize(width: 24, height: 24), + paths: [ + "M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z", + "M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z", + "M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z", + ] + ) + + static let profileFallback = vectorIcon( + viewport: CGSize(width: 24, height: 24), + paths: [ + "M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z", + ] + ) + + static func profileAvatar( + name: String?, + avatarColor: UIColor?, + backgroundColor: UIColor?, + avatarImage: UIImage?, + selected: Bool, + accent: UIColor + ) -> UIImage { + guard name != nil || avatarColor != nil || avatarImage != nil else { + return profileFallback + } + + let size = CGSize(width: 28, height: 28) + let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1) + let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15) + let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5) + let initial = name? + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix(1) + .uppercased() ?? "" + + return UIGraphicsImageRenderer(size: size).image { _ in + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) + fillColor.setFill() + UIBezierPath(ovalIn: rect).fill() + + if let avatarImage { + UIBezierPath(ovalIn: rect).addClip() + drawAspectFill(image: avatarImage, in: rect) + } else if !initial.isEmpty { + let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: baseColor, + ] + let textSize = initial.size(withAttributes: attributes) + initial.draw( + at: CGPoint( + x: rect.midX - textSize.width / 2, + y: rect.midY - textSize.height / 2 + ), + withAttributes: attributes + ) + } else { + profileFallback + .withTintColor(baseColor, renderingMode: .alwaysOriginal) + .draw(in: rect.insetBy(dx: 5.5, dy: 5.5)) + } + + borderColor.setStroke() + let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) + borderPath.lineWidth = 1.5 + borderPath.stroke() + }.withRenderingMode(.alwaysOriginal) + } + + private static func drawInViewport( + context: CGContext, + rect: CGRect, + viewport: CGSize, + draw: () -> Void + ) { + let scale = min(rect.width / viewport.width, rect.height / viewport.height) + let x = rect.midX - viewport.width * scale / 2 + let y = rect.midY - viewport.height * scale / 2 + context.saveGState() + context.translateBy(x: x, y: y) + context.scaleBy(x: scale, y: scale) + draw() + context.restoreGState() + } + + private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage { + drawnIcon(size: size) { context, rect in + drawInViewport(context: context, rect: rect, viewport: viewport) { + context.setFillColor(UIColor.black.cgColor) + paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in + context.addPath(path) + context.fillPath(using: .evenOdd) + } + } + } + } + + private static func drawnIcon( + size: CGSize = CGSize(width: 25, height: 25), + draw: @escaping (CGContext, CGRect) -> Void + ) -> UIImage { + UIGraphicsImageRenderer(size: size).image { rendererContext in + draw(rendererContext.cgContext, CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + } + + private static func drawAspectFill(image: UIImage, in rect: CGRect) { + guard image.size.width > 0, image.size.height > 0 else { return } + let scale = max(rect.width / image.size.width, rect.height / image.size.height) + let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let drawRect = CGRect( + x: rect.midX - drawSize.width / 2, + y: rect.midY - drawSize.height / 2, + width: drawSize.width, + height: drawSize.height + ) + image.draw(in: drawRect) + } + + private struct SVGPath { + private enum Token { + case command(Character) + case number(CGFloat) + } + + let data: String + + var cgPath: CGPath? { + let tokens = Self.tokens(from: data) + var index = 0 + var command: Character? + var current = CGPoint.zero + var subpathStart = CGPoint.zero + let path = CGMutablePath() + + func hasNumber() -> Bool { + guard index < tokens.count else { return false } + if case .number = tokens[index] { return true } + return false + } + + func readNumber() -> CGFloat? { + guard index < tokens.count else { return nil } + guard case let .number(value) = tokens[index] else { return nil } + index += 1 + return value + } + + func readPoint(relative: Bool) -> CGPoint? { + guard let x = readNumber(), let y = readNumber() else { return nil } + let point = CGPoint(x: x, y: y) + return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point + } + + while index < tokens.count { + if case let .command(value) = tokens[index] { + command = value + index += 1 + } + + guard let activeCommand = command else { return nil } + let relative = activeCommand.isLowercase + + switch activeCommand.uppercased() { + case "M": + guard let point = readPoint(relative: relative) else { return nil } + path.move(to: point) + current = point + subpathStart = point + command = relative ? "l" : "L" + case "L": + while hasNumber() { + guard let point = readPoint(relative: relative) else { return nil } + path.addLine(to: point) + current = point + } + case "H": + while hasNumber() { + guard let x = readNumber() else { return nil } + let point = CGPoint(x: relative ? current.x + x : x, y: current.y) + path.addLine(to: point) + current = point + } + case "V": + while hasNumber() { + guard let y = readNumber() else { return nil } + let point = CGPoint(x: current.x, y: relative ? current.y + y : y) + path.addLine(to: point) + current = point + } + case "C": + while hasNumber() { + guard + let c1 = readPoint(relative: relative), + let c2 = readPoint(relative: relative), + let end = readPoint(relative: relative) + else { return nil } + path.addCurve(to: end, control1: c1, control2: c2) + current = end + } + case "Z": + path.closeSubpath() + current = subpathStart + default: + return nil + } + } + + return path + } + + private static func tokens(from data: String) -> [Token] { + let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(data.startIndex.. UIImage { + guard tab == .settings else { + return tab.iconImage + } + + let defaults = UserDefaults.standard + return NuvioNativeTabIcon.profileAvatar( + name: defaults.string(forKey: Self.nativeProfileNameKey), + avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)), + backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)), + avatarImage: profileAvatarImage, + selected: selected, + accent: accent + ) + } + + private func refreshProfileAvatarImageIfNeeded() { + let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey) + guard urlString != profileAvatarImageURL else { return } + + profileAvatarImageTask?.cancel() + profileAvatarImageTask = nil + profileAvatarImageURL = urlString + profileAvatarImage = nil + + guard let urlString, let url = URL(string: urlString) else { return } + + profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard + let self, + let data, + let image = UIImage(data: data) + else { return } + + DispatchQueue.main.async { + guard self.profileAvatarImageURL == urlString else { return } + self.profileAvatarImage = image + self.applyNativeTabBarAppearance() + } + } + profileAvatarImageTask?.resume() + } +} + +private extension UIColor { + convenience init?(hexString: String?) { + guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6, let rgb = UInt64(value, radix: 16) else { + return nil + } + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } } struct ComposeView: UIViewControllerRepresentable { From 5926f239e30725fc139595942a8052709f898ec4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 19:57:12 +0530 Subject: [PATCH 12/18] update strings --- composeApp/src/commonMain/composeResources/values/strings.xml | 2 +- composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 42d4c850..81460967 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -450,7 +450,7 @@ Choose Language Settings for the Continue Watching section. Liquid Glass - Use the native iPhone tab bar on iOS 26 and later. + Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. Tune card width and corner radius. DISPLAY HOME diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 51020aaf..0cda0cb5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -314,6 +314,7 @@ fun App() { LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() + AvatarRepository.fetchAvatars() } val authState by AuthRepository.state.collectAsStateWithLifecycle() From eee6d1a2a81793bbfdfc9b98356d7f7d51b16cfc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 20:07:01 +0530 Subject: [PATCH 13/18] ref(ios): implement native tab visibily change --- .../commonMain/kotlin/com/nuvio/app/App.kt | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 0cda0cb5..987a0643 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -61,6 +61,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -576,6 +578,32 @@ private fun MainAppContent( NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) } + DisposableEffect( + navController, + liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled, + initialHomeReady, + ) { + fun publishNativeTabVisibilityForCurrentRoute() { + val visible = liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled && + initialHomeReady && + navController.currentDestination?.hasRoute() == true + NativeTabBridge.publishTabBarVisible(visible) + } + + val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ -> + publishNativeTabVisibilityForCurrentRoute() + } + + publishNativeTabVisibilityForCurrentRoute() + navController.addOnDestinationChangedListener(destinationChangedListener) + onDispose { + navController.removeOnDestinationChangedListener(destinationChangedListener) + NativeTabBridge.publishTabBarVisible(false) + } + } + LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() EpisodeReleaseNotificationsRepository.refreshAsync() @@ -956,13 +984,6 @@ private fun MainAppContent( com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex) } - DisposableEffect(useNativeBottomTabs) { - NativeTabBridge.publishTabBarVisible(useNativeBottomTabs) - onDispose { - NativeTabBridge.publishTabBarVisible(false) - } - } - Scaffold( modifier = Modifier .fillMaxSize() From b293157fee9445477a422585b49630cd3091c771 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 21:00:51 +0530 Subject: [PATCH 14/18] ref: add season/episode number in downloads --- .../app/features/downloads/DownloadsModels.kt | 10 ++++ .../app/features/downloads/DownloadsScreen.kt | 48 ++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt index 94769875..48da488c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt @@ -98,3 +98,13 @@ enum class DownloadEnqueueResult { } } } + +internal fun List.sortedForSeriesDownloads(): List = + sortedWith(downloadSeriesEpisodeComparator) + +internal val downloadSeriesEpisodeComparator: Comparator = + compareBy { it.seasonNumber ?: Int.MAX_VALUE } + .thenBy { it.episodeNumber ?: Int.MAX_VALUE } + .thenBy { it.episodeTitle?.trim().orEmpty().lowercase() } + .thenBy { it.title.trim().lowercase() } + .thenBy { it.id } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index d8952adf..b1fe7e33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -56,7 +56,7 @@ fun DownloadsScreen( val completedEpisodes = remember(uiState.items) { uiState.completedItems .filter { it.isEpisode } - .sortedByDescending { it.updatedAtEpochMs } + .sortedForSeriesDownloads() } val selectedShowTitle = remember(selectedShowId, completedEpisodes) { @@ -229,6 +229,7 @@ private fun LazyListScope.downloadsShowContent( ) { val showEpisodes = episodes .filter { it.parentMetaId == showId } + .sortedForSeriesDownloads() val seasons = showEpisodes .groupBy { it.seasonNumber ?: 0 } @@ -268,10 +269,7 @@ private fun LazyListScope.downloadsShowContent( ) } - val sortedEpisodes = entries.sortedWith( - compareBy { it.episodeNumber ?: Int.MAX_VALUE } - .thenByDescending { it.updatedAtEpochMs }, - ) + val sortedEpisodes = entries.sortedForSeriesDownloads() items( items = sortedEpisodes, @@ -298,6 +296,12 @@ private fun DownloadRow( onRetry: () -> Unit, onDelete: () -> Unit, ) { + val displayTitle = item.displayTitle() + val displaySubtitle = downloadDisplaySubtitle( + item = item, + displayTitle = displayTitle, + ) + Surface( modifier = Modifier .fillMaxWidth() @@ -322,7 +326,7 @@ private fun DownloadRow( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = item.title, + text = displayTitle, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -330,7 +334,7 @@ private fun DownloadRow( overflow = TextOverflow.Ellipsis, ) Text( - text = item.displaySubtitle, + text = displaySubtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -403,6 +407,36 @@ private fun DownloadRow( } } +private fun DownloadItem.displayTitle(): String = + if (isEpisode) { + episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: title + } else { + title + } + +@Composable +private fun downloadDisplaySubtitle( + item: DownloadItem, + displayTitle: String, +): String { + val seasonNumber = item.seasonNumber + val episodeNumber = item.episodeNumber + if (seasonNumber == null || episodeNumber == null) { + return item.displaySubtitle + } + + val episodeCode = stringResource( + Res.string.compose_player_episode_code_full, + seasonNumber, + episodeNumber, + ) + return listOf( + episodeCode, + item.episodeTitle?.trim().orEmpty().takeIf { it.isNotBlank() && it != displayTitle }, + item.title.trim().takeIf { it.isNotBlank() && it != displayTitle }, + ).filterNotNull().joinToString(" • ") +} + @Composable private fun SectionTitle(title: String) { Text( From 5a0b6237738731571e0438818868af34d7f65fad Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 23:46:38 +0530 Subject: [PATCH 15/18] ref: add collections support to local account data cleaner --- .../PlatformLocalAccountDataCleaner.android.kt | 1 + .../collection/CollectionSyncService.kt | 18 ++++++++---------- .../PlatformLocalAccountDataCleaner.ios.kt | 1 + 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index de84c4a5..9edf1191 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_episode_release_notifications", "nuvio_episode_release_notifications_platform", "nuvio_watch_progress", + "nuvio_collections", "nuvio_plugins", ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index de0931ec..e1046712 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -56,16 +57,13 @@ object CollectionSyncService { return } - val remoteJson = blob.collectionsJson.toString() - val localJson = CollectionRepository.exportToJson() - - if (remoteJson == "[]" || remoteJson == "null") { - val currentCollections = CollectionRepository.collections.value - if (currentCollections.isNotEmpty()) { - log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" } - return - } + val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) { + JsonArray(emptyList()) + } else { + blob.collectionsJson } + val remoteJson = remoteCollectionsJson.toString() + val localJson = CollectionRepository.exportToJson() if (remoteJson == localJson) { log.d { "pullFromServer — remote matches local, no update needed" } @@ -78,7 +76,7 @@ object CollectionSyncService { if (remoteCollections != null) { isSyncingFromRemote = true - CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson) + CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson) isSyncingFromRemote = false log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" } } else { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 71d71168..553140ee 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner { "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "collections_payload", ) actual fun wipe() { From 1e75f416e4fe45bb8ecec6863d8423b661d4bd83 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 01:33:37 +0530 Subject: [PATCH 16/18] feat: adding retry logic to library --- .../app/features/library/LibraryScreen.kt | 24 +++++------- .../features/trakt/TraktLibraryRepository.kt | 37 +++++++++++++++---- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 9b48e32d..efe6ded9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -50,6 +50,12 @@ fun LibraryScreen( var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val retryLibraryLoad: () -> Unit = { + NetworkStatusRepository.requestRefresh(force = true) + coroutineScope.launch { + LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) + } + } LaunchedEffect(networkStatusUiState.condition, isTraktSource) { when (networkStatusUiState.condition) { @@ -110,14 +116,7 @@ fun LibraryScreen( NuvioNetworkOfflineCard( condition = networkStatusUiState.condition, modifier = Modifier.padding(horizontal = 16.dp), - onRetry = { - NetworkStatusRepository.requestRefresh(force = true) - if (isTraktSource) { - coroutineScope.launch { - LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) - } - } - }, + onRetry = retryLibraryLoad, ) } else { HomeEmptyStateCard( @@ -128,6 +127,8 @@ fun LibraryScreen( stringResource(Res.string.library_load_failed) }, message = uiState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = retryLibraryLoad, ) } } @@ -139,12 +140,7 @@ fun LibraryScreen( NuvioNetworkOfflineCard( condition = networkStatusUiState.condition, modifier = Modifier.padding(horizontal = 16.dp), - onRetry = { - NetworkStatusRepository.requestRefresh(force = true) - coroutineScope.launch { - LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) - } - }, + onRetry = retryLibraryLoad, ) } else { HomeEmptyStateCard( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 0dc06966..03cdfc67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -36,6 +36,7 @@ private const val LIST_FETCH_CONCURRENCY = 4 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L private const val FORCE_REFRESH_DEDUP_MS = 10_000L +private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240 data class TraktLibraryUiState( val listTabs: List = emptyList(), @@ -159,21 +160,20 @@ object TraktLibraryRepository { errorMessage = null, ) } - }.onFailure { error -> + } + result.exceptionOrNull()?.let { error -> if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt library: ${error.message}" } - }.getOrNull() - - if (result == null) { - _uiState.value = current.copy( + log.w(error) { "Failed to refresh Trakt library" } + _uiState.value = _uiState.value.copy( isLoading = false, hasLoaded = true, - errorMessage = getString(Res.string.trakt_library_load_failed), + errorMessage = traktLibraryLoadErrorMessage(error), ) return } - _uiState.value = result.copy( + val snapshot = result.getOrThrow() + _uiState.value = snapshot.copy( isLoading = false, hasLoaded = true, errorMessage = null, @@ -414,6 +414,27 @@ object TraktLibraryRepository { TraktLibraryStorage.savePayload(json.encodeToString(payload)) } + private suspend fun traktLibraryLoadErrorMessage(error: Throwable): String { + val fallback = getString(Res.string.trakt_library_load_failed) + val detail = error.userVisibleMessage() + return when { + detail.isBlank() -> fallback + detail.equals(fallback, ignoreCase = true) -> fallback + else -> detail + } + } + + private fun Throwable.userVisibleMessage(): String { + val raw = message?.trim()?.takeIf { it.isNotBlank() } + ?: toString().trim() + val firstLine = raw.lines().firstOrNull()?.trim().orEmpty() + return if (firstLine.length <= MAX_VISIBLE_ERROR_MESSAGE_LENGTH) { + firstLine + } else { + firstLine.take(MAX_VISIBLE_ERROR_MESSAGE_LENGTH).trimEnd() + "..." + } + } + private suspend fun fetchListTabs(headers: Map): List { val watchlistTabs = listOf( TraktListTab( From 81babba3edb9ac39bb1dbe2c04874627cd4b25d0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 02:01:11 +0530 Subject: [PATCH 17/18] feat: episode ratings api --- composeApp/build.gradle.kts | 14 + .../details/ImdbEpisodeRatingsRepository.kt | 112 +++++++ .../app/features/details/MetaDetailsScreen.kt | 53 ++++ .../app/features/details/SeriesGraphApi.kt | 65 +++++ .../details/components/DetailSeriesContent.kt | 275 ++++++++++++------ 5 files changed, 435 insertions(+), 84 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 73a97208..71d3b924 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -76,6 +76,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/details").apply { + mkdirs() + resolve("ImdbEpisodeRatingsConfig.kt").writeText( + """ + |package com.nuvio.app.features.details + | + |object ImdbEpisodeRatingsConfig { + | const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}" + | const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt new file mode 100644 index 00000000..6a32a874 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt @@ -0,0 +1,112 @@ +package com.nuvio.app.features.details + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.library.LibraryClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +object ImdbEpisodeRatingsRepository { + private data class CacheEntry( + val ratings: Map, Double>, + val expiresAtMs: Long, + ) + + private val log = Logger.withTag("ImdbEpisodeRatingsRepo") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val cache = mutableMapOf() + private val inFlight = mutableMapOf, Double>>>() + + suspend fun getEpisodeRatings( + imdbId: String?, + tmdbId: Int?, + ): Map, Double> { + val normalizedImdbId = normalizeImdbId(imdbId) + val normalizedTmdbId = tmdbId?.takeIf { it > 0 } + if (normalizedImdbId == null && normalizedTmdbId == null) return emptyMap() + + val cacheKey = normalizedImdbId?.let { "imdb:$it" } ?: "tmdb:$normalizedTmdbId" + val now = currentTimeMs() + mutex.withLock { + cache[cacheKey]?.let { cached -> + if (cached.expiresAtMs > now) return cached.ratings + cache.remove(cacheKey) + } + } + + val deferred = mutex.withLock { + inFlight[cacheKey] ?: scope.async { + try { + fetchEpisodeRatings( + imdbId = normalizedImdbId, + tmdbId = normalizedTmdbId, + ).also { ratings -> + mutex.withLock { + cache[cacheKey] = CacheEntry( + ratings = ratings, + expiresAtMs = currentTimeMs() + CACHE_TTL_MS, + ) + } + } + } finally { + mutex.withLock { + inFlight.remove(cacheKey) + } + } + }.also { created -> + inFlight[cacheKey] = created + } + } + + return deferred.await() + } + + fun clearCache() { + cache.clear() + inFlight.clear() + } + + private suspend fun fetchEpisodeRatings( + imdbId: String?, + tmdbId: Int?, + ): Map, Double> { + if (!imdbId.isNullOrBlank()) { + val primary = toRatingsMap(ImdbTapframeApi.getSeasonRatings(imdbId)) + if (primary.isNotEmpty()) return primary + log.w { "Primary episode ratings empty for imdbId=$imdbId, trying fallback" } + } + + if (tmdbId != null) { + return toRatingsMap(SeriesGraphApi.getSeasonRatings(tmdbId)) + } + + return emptyMap() + } + + private fun toRatingsMap(payload: List): Map, Double> = + buildMap { + payload.forEach { season -> + season.episodes.orEmpty().forEach { episode -> + val seasonNumber = episode.seasonNumber ?: return@forEach + val episodeNumber = episode.episodeNumber ?: return@forEach + val voteAverage = episode.voteAverage?.takeIf { it > 0.0 } ?: return@forEach + put(seasonNumber to episodeNumber, voteAverage) + } + } + } + + private fun normalizeImdbId(value: String?): String? = + value + ?.trim() + ?.substringBefore(':') + ?.takeIf { it.startsWith("tt", ignoreCase = true) } + + private fun currentTimeMs(): Long = LibraryClock.nowEpochMs() + + private const val CACHE_TTL_MS = 30L * 60L * 1000L +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 80c724a3..d8bfbf27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -81,6 +81,7 @@ import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentsRepository @@ -167,6 +168,7 @@ fun MetaDetailsScreen( var pickerMembership by remember(type, id) { mutableStateOf>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } + var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && @@ -194,6 +196,30 @@ fun MetaDetailsScreen( isCommentsLoading = false } + LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) { + val metaForRatings = displayedMeta + if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) { + episodeImdbRatings = emptyMap() + return@LaunchedEffect + } + + val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id) + val tmdbId = extractTmdbId(metaForRatings.id) + ?: extractTmdbId(id) + ?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull() + ?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull() + + if (imdbId == null && tmdbId == null) { + episodeImdbRatings = emptyMap() + return@LaunchedEffect + } + + episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings( + imdbId = imdbId, + tmdbId = tmdbId, + ) + } + LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) { if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) { autoLoadAttempted = true @@ -656,6 +682,7 @@ fun MetaDetailsScreen( commentsCurrentPage = commentsCurrentPage, commentsPageCount = commentsPageCount, commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, onRetryComments = { detailsScope.launch { isCommentsLoading = true @@ -937,6 +964,30 @@ fun MetaDetailsScreen( } } +private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { + val normalizedType = type.trim().lowercase() + val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null } + return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") +} + +private fun extractImdbId(value: String?): String? = + value + ?.trim() + ?.split(':', '/', '?', '&') + ?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) } + ?.takeIf { it.length > 2 } + +private fun extractTmdbId(value: String?): Int? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isBlank()) return null + return trimmed + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.substringBefore('/') + ?.toIntOrNull() +} + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( @@ -965,6 +1016,7 @@ private fun ConfiguredMetaSections( commentsCurrentPage: Int, commentsPageCount: Int, commentsError: String?, + episodeImdbRatings: Map, Double>, onRetryComments: () -> Unit, onLoadMoreComments: () -> Unit, onCommentClick: (TraktCommentReview) -> Unit, @@ -1064,6 +1116,7 @@ private fun ConfiguredMetaSections( episodeCardStyle = settings.episodeCardStyle, progressByVideoId = progressByVideoId, watchedKeys = watchedKeys, + episodeRatings = episodeImdbRatings, blurUnwatchedEpisodes = blurUnwatchedEpisodes, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt new file mode 100644 index 00000000..c46996ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.features.details + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +internal object SeriesGraphApi { + suspend fun getSeasonRatings(tmdbId: Int): List = + requestSeasonRatings( + baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL, + showId = tmdbId.toString(), + ) +} + +internal object ImdbTapframeApi { + suspend fun getSeasonRatings(imdbId: String): List = + requestSeasonRatings( + baseUrl = ImdbEpisodeRatingsConfig.IMDB_TAPFRAME_API_BASE_URL, + showId = imdbId, + ) +} + +@Serializable +internal data class SeriesGraphEpisodeRatingDto( + @SerialName("season_number") val seasonNumber: Int? = null, + @SerialName("episode_number") val episodeNumber: Int? = null, + @SerialName("vote_average") val voteAverage: Double? = null, + val name: String? = null, + val tconst: String? = null, +) + +@Serializable +internal data class SeriesGraphSeasonRatingsDto( + val episodes: List? = null, +) + +private val seriesGraphLog = Logger.withTag("SeriesGraphApi") +private val seriesGraphJson = Json { ignoreUnknownKeys = true } + +private suspend fun requestSeasonRatings( + baseUrl: String, + showId: String, +): List { + val resolvedBaseUrl = baseUrl.trim().trimEnd('/') + if (resolvedBaseUrl.isBlank()) return emptyList() + + return runCatching { + val response = httpRequestRaw( + method = "GET", + url = "$resolvedBaseUrl/api/shows/$showId/season-ratings", + headers = mapOf("Accept" to "application/json"), + body = "", + ) + if (response.status !in 200..299 || response.body.isBlank()) { + seriesGraphLog.w { "Season ratings request failed for $showId (${response.status})" } + return emptyList() + } + seriesGraphJson.decodeFromString>(response.body) + }.onFailure { error -> + seriesGraphLog.w(error) { "Season ratings request failed for $showId" } + }.getOrDefault(emptyList()) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 10f42141..e5140b74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -15,12 +15,14 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -77,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import kotlin.math.absoluteValue +import kotlin.math.roundToInt private val log = Logger.withTag("SeriesContent") @@ -91,6 +96,7 @@ fun DetailSeriesContent( episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, progressByVideoId: Map = emptyMap(), watchedKeys: Set = emptySet(), + episodeRatings: Map, Double> = emptyMap(), blurUnwatchedEpisodes: Boolean = false, onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, @@ -278,6 +284,7 @@ fun DetailSeriesContent( watchedKeys = watchedKeys, fallbackImage = meta.background ?: meta.poster, progressByVideoId = progressByVideoId, + episodeRatings = episodeRatings, blurUnwatchedEpisodes = blurUnwatchedEpisodes, preferredEpisodeNumber = preferredEpisodeNumber, onEpisodeClick = onEpisodeClick, @@ -298,6 +305,7 @@ fun DetailSeriesContent( video = episode, fallbackImage = meta.background ?: meta.poster, progressEntry = progressByVideoId[episodeVideoId], + imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] }, isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || WatchingState.isEpisodeWatched( watchedKeys = watchedKeys, @@ -557,6 +565,7 @@ private fun EpisodeHorizontalRow( watchedKeys: Set, fallbackImage: String?, progressByVideoId: Map, + episodeRatings: Map, Double>, blurUnwatchedEpisodes: Boolean, preferredEpisodeNumber: Int? = null, onEpisodeClick: ((MetaVideo) -> Unit)?, @@ -602,6 +611,7 @@ private fun EpisodeHorizontalRow( video = episode, fallbackImage = fallbackImage, progressEntry = progressByVideoId[episodeVideoId], + imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] }, isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || WatchingState.isEpisodeWatched( watchedKeys = watchedKeys, @@ -624,6 +634,7 @@ private fun EpisodeHorizontalCard( video: MetaVideo, fallbackImage: String?, progressEntry: WatchProgressEntry?, + imdbRating: Double?, isWatched: Boolean, blurUnwatchedEpisodes: Boolean, metrics: EpisodeHorizontalCardMetrics, @@ -631,6 +642,9 @@ private fun EpisodeHorizontalCard( onLongPress: (() -> Unit)? = null, ) { val cardShape = RoundedCornerShape(metrics.cornerRadius) + val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) } + val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } } + val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) } Box( modifier = Modifier .width(metrics.cardWidth) @@ -676,30 +690,6 @@ private fun EpisodeHorizontalCard( ), ) - Box( - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = metrics.contentPadding, top = metrics.contentPadding) - .clip(RoundedCornerShape(metrics.badgeRadius)) - .background(Color.Black.copy(alpha = 0.75f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.18f), - shape = RoundedCornerShape(metrics.badgeRadius), - ) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - text = video.episodeBadge(), - style = MaterialTheme.typography.labelMedium.copy( - fontSize = metrics.badgeTextSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.5.sp, - ), - color = Color.White, - ) - } - NuvioAnimatedWatchedBadge( isVisible = isWatched, modifier = Modifier @@ -719,6 +709,15 @@ private fun EpisodeHorizontalCard( ), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + EpisodeCodeBadge( + text = video.episodeBadge(), + textSize = metrics.badgeTextSize, + radius = metrics.badgeRadius, + horizontalPadding = metrics.badgeHorizontalPadding, + verticalPadding = metrics.badgeVerticalPadding, + backgroundAlpha = 0.42f, + ) + Text( text = video.title, style = MaterialTheme.typography.titleMedium.copy( @@ -744,27 +743,39 @@ private fun EpisodeHorizontalCard( ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes -> - Text( - text = formatEpisodeRuntime(runtimeMinutes), - style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), - color = Color.White.copy(alpha = 0.78f), - maxLines = 1, - ) - } - video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate -> - Text( - text = formattedDate, - style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), - color = Color.White.copy(alpha = 0.78f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (runtimeLabel != null || ratingLabel != null || formattedDate != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + runtimeLabel?.let { runtime -> + Text( + text = runtime, + style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), + color = Color.White.copy(alpha = 0.78f), + maxLines = 1, + ) + } + ratingLabel?.let { rating -> + ImdbEpisodeRatingBadge( + rating = rating, + logoWidth = metrics.imdbLogoWidth, + logoHeight = metrics.imdbLogoHeight, + textSize = metrics.metaTextSize, + ) + } + Spacer(modifier = Modifier.weight(1f)) + formattedDate?.let { date -> + Text( + text = date, + style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), + color = Color.White.copy(alpha = 0.78f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + ) + } } } } @@ -803,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics( val metaTextSize: androidx.compose.ui.unit.TextUnit, val badgeTextSize: androidx.compose.ui.unit.TextUnit, val badgeRadius: Dp, + val badgeHorizontalPadding: Dp, + val badgeVerticalPadding: Dp, + val imdbLogoWidth: Dp, + val imdbLogoHeight: Dp, ) @Composable @@ -825,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori overviewMaxLines = 3, metaTextSize = 12.sp, badgeTextSize = 11.sp, - badgeRadius = 6.dp, + badgeRadius = 8.dp, + badgeHorizontalPadding = 10.dp, + badgeVerticalPadding = 5.dp, + imdbLogoWidth = 28.dp, + imdbLogoHeight = 14.dp, ) maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics( @@ -844,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori overviewMaxLines = 3, metaTextSize = 12.sp, badgeTextSize = 10.sp, - badgeRadius = 6.dp, + badgeRadius = 7.dp, + badgeHorizontalPadding = 9.dp, + badgeVerticalPadding = 4.dp, + imdbLogoWidth = 26.dp, + imdbLogoHeight = 13.dp, ) maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics( @@ -863,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori overviewMaxLines = 2, metaTextSize = 11.sp, badgeTextSize = 10.sp, - badgeRadius = 5.dp, + badgeRadius = 6.dp, + badgeHorizontalPadding = 8.dp, + badgeVerticalPadding = 4.dp, + imdbLogoWidth = 24.dp, + imdbLogoHeight = 12.dp, ) else -> EpisodeHorizontalCardMetrics( @@ -883,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori metaTextSize = 10.sp, badgeTextSize = 9.sp, badgeRadius = 5.dp, + badgeHorizontalPadding = 7.dp, + badgeVerticalPadding = 3.dp, + imdbLogoWidth = 22.dp, + imdbLogoHeight = 11.dp, ) } } @@ -892,12 +923,73 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String { return formatRuntimeFromMinutes(runtimeMinutes) } +@Composable +private fun EpisodeCodeBadge( + text: String, + textSize: androidx.compose.ui.unit.TextUnit, + radius: Dp, + horizontalPadding: Dp, + verticalPadding: Dp, + backgroundAlpha: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(radius)) + .background(Color.Black.copy(alpha = backgroundAlpha)) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = textSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.sp, + ), + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + ) + } +} + +@Composable +private fun ImdbEpisodeRatingBadge( + rating: String, + logoWidth: Dp, + logoHeight: Dp, + textSize: androidx.compose.ui.unit.TextUnit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(Res.drawable.rating_imdb), + contentDescription = stringResource(Res.string.source_imdb), + modifier = Modifier + .width(logoWidth) + .height(logoHeight), + contentScale = ContentScale.Fit, + ) + Text( + text = rating, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = textSize, + fontWeight = FontWeight.SemiBold, + ), + color = Color(0xFFF5C518), + maxLines = 1, + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun EpisodeListCard( video: MetaVideo, fallbackImage: String?, progressEntry: WatchProgressEntry?, + imdbRating: Double?, isWatched: Boolean, blurUnwatchedEpisodes: Boolean, sizing: SeriesContentSizing, @@ -906,6 +998,8 @@ private fun EpisodeListCard( onLongPress: (() -> Unit)? = null, ) { val cardShape = RoundedCornerShape(sizing.cardRadius) + val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) } + val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } } Box( modifier = modifier .fillMaxWidth() @@ -952,32 +1046,17 @@ private fun EpisodeListCard( ) } - Box( + EpisodeCodeBadge( + text = video.episodeBadge(), + textSize = sizing.badgeTextSize, + radius = sizing.badgeRadius, + horizontalPadding = sizing.badgeHorizontalPadding, + verticalPadding = sizing.badgeVerticalPadding, + backgroundAlpha = 0.85f, modifier = Modifier .align(Alignment.TopStart) - .padding(start = 8.dp, top = 8.dp) - .clip(RoundedCornerShape(sizing.badgeRadius)) - .background(Color.Black.copy(alpha = 0.85f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.2f), - shape = RoundedCornerShape(sizing.badgeRadius), - ) - .padding( - horizontal = sizing.badgeHorizontalPadding, - vertical = sizing.badgeVerticalPadding, - ), - ) { - Text( - text = video.episodeBadge(), - style = MaterialTheme.typography.labelMedium.copy( - fontSize = sizing.badgeTextSize, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.3.sp, - ), - color = Color.White, - ) - } + .padding(start = 8.dp, top = 8.dp), + ) NuvioAnimatedWatchedBadge( isVisible = isWatched, @@ -1005,24 +1084,39 @@ private fun EpisodeListCard( fontSize = sizing.titleTextSize, fontWeight = FontWeight.Bold, lineHeight = sizing.titleLineHeight, - letterSpacing = 0.3.sp, + letterSpacing = 0.sp, ), color = MaterialTheme.colorScheme.onSurface, maxLines = sizing.titleMaxLines, overflow = TextOverflow.Ellipsis, ) - video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate -> - Text( - text = formattedDate, - style = MaterialTheme.typography.labelMedium.copy( - fontSize = sizing.metaTextSize, - fontWeight = FontWeight.Medium, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (formattedDate != null || ratingLabel != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + formattedDate?.let { date -> + Text( + text = date, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = sizing.metaTextSize, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ratingLabel?.let { rating -> + ImdbEpisodeRatingBadge( + rating = rating, + logoWidth = 24.dp, + logoHeight = 12.dp, + textSize = sizing.metaTextSize, + ) + } + } } if (!video.overview.isNullOrBlank()) { @@ -1225,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String = localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty() else -> runBlocking { getString(Res.string.details_episode_badge_file) } } + +private fun MetaVideo.seasonEpisodeKey(): Pair? { + val seasonNumber = season ?: return null + val episodeNumber = episode ?: return null + return seasonNumber to episodeNumber +} + +private fun formatEpisodeRating(rating: Double): String { + val roundedTenths = (rating * 10.0).roundToInt() + val whole = roundedTenths / 10 + val tenth = (roundedTenths % 10).absoluteValue + return "$whole.$tenth" +} From 2fa918fe4492d7dec7f8fbe6be406aecc7bd42c4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 13:14:19 +0530 Subject: [PATCH 18/18] feat: add support for custom avatar url --- .../composeResources/values/strings.xml | 5 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 8 +- .../features/profiles/ProfileEditScreen.kt | 89 ++++++++++++++++--- .../app/features/profiles/ProfileModels.kt | 19 ++++ .../features/profiles/ProfileRepository.kt | 9 +- .../profiles/ProfileSelectionScreen.kt | 15 ++-- .../features/profiles/ProfileSwitcherTab.kt | 24 +++-- 7 files changed, 134 insertions(+), 35 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 81460967..e8b01632 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1007,9 +1007,14 @@ Locked. Try again in %1$ds Avatar options will appear here when the catalog loads. Avatar: %1$s + Enter a valid http:// or https:// image URL. Choose an avatar Choose an avatar below. Create Profile + Custom avatar URL selected. + Custom avatar URL + Paste an image link, or leave this empty to use the built-in avatar catalog. + https://example.com/avatar.png All data for "%1$s" will be permanently deleted. Delete Profile Add Profile diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 987a0643..3eebbdac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -135,7 +135,7 @@ import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab -import com.nuvio.app.features.profiles.avatarStorageUrl +import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen @@ -331,6 +331,7 @@ fun App() { profileState.activeProfile?.name, profileState.activeProfile?.avatarColorHex, profileState.activeProfile?.avatarId, + profileState.activeProfile?.avatarUrl, profileAvatars, ) { val activeProfile = profileState.activeProfile @@ -340,10 +341,7 @@ fun App() { NativeTabBridge.publishProfileTabIcon( name = activeProfile?.name, avatarColorHex = activeProfile?.avatarColorHex, - avatarImageUrl = avatarItem - ?.storagePath - ?.takeIf { it.isNotBlank() } - ?.let(::avatarStorageUrl), + avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) }, avatarBackgroundColorHex = avatarItem?.bgColor, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 83d26dc6..5f00697d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -78,6 +78,7 @@ fun ProfileEditScreen( var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") } var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) } + var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) } var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) } var isSaving by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } @@ -90,17 +91,20 @@ fun ProfileEditScreen( AvatarRepository.fetchAvatars() AvatarRepository.refreshAvatars() } - LaunchedEffect(isNew, avatars, selectedAvatarId) { - if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) { + LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) { + if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) { selectedAvatarId = avatars.first().id } } + val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) } + val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null val selectedAvatarItem = remember(selectedAvatarId, avatars) { selectedAvatarId?.let { id -> avatars.find { it.id == id } } } - val previewAccent = remember(selectedAvatarItem, fallbackColorHex) { - parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex) + val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null + val previewAccent = remember(visibleAvatarItem, fallbackColorHex) { + parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex) } NuvioScreen(modifier = modifier) { @@ -123,12 +127,47 @@ fun ProfileEditScreen( usesPrimaryAddons = usesPrimaryAddons, onNameChange = { name = it }, onUsesPrimaryAddonsChange = { usesPrimaryAddons = it }, - selectedAvatar = selectedAvatarItem, + selectedAvatar = visibleAvatarItem, + customAvatarUrl = customAvatarUrl, accentColor = previewAccent, hasAvatarChoices = avatars.isNotEmpty(), ) } + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringResource(Res.string.profile_custom_avatar_url), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.profile_custom_avatar_url_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NuvioInputField( + value = avatarUrl, + onValueChange = { value -> + avatarUrl = value + if (value.isNotBlank()) { + selectedAvatarId = null + } + }, + placeholder = stringResource(Res.string.profile_custom_avatar_url_placeholder), + ) + if (avatarUrlIsInvalid) { + Text( + text = stringResource(Res.string.profile_avatar_url_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + item { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -165,8 +204,11 @@ fun ProfileEditScreen( AvatarChoiceItem( avatar = avatar, size = avatarSize, - isSelected = avatar.id == selectedAvatarId, - onClick = { selectedAvatarId = avatar.id }, + isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId, + onClick = { + avatarUrl = "" + selectedAvatarId = avatar.id + }, ) } } @@ -220,16 +262,17 @@ fun ProfileEditScreen( } else { stringResource(Res.string.collections_editor_save_changes) }, - enabled = name.isNotBlank() && !isSaving, + enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving, onClick = { isSaving = true scope.launch { - val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex + val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex if (isNew) { ProfileRepository.createProfile( name = name, avatarColorHex = avatarColorHex, - avatarId = selectedAvatarId, + avatarId = if (customAvatarUrl == null) selectedAvatarId else null, + avatarUrl = customAvatarUrl, usesPrimaryAddons = usesPrimaryAddons, ) } else { @@ -237,7 +280,8 @@ fun ProfileEditScreen( profileIndex = currentProfile!!.profileIndex, name = name, avatarColorHex = avatarColorHex, - avatarId = selectedAvatarId, + avatarId = if (customAvatarUrl == null) selectedAvatarId else null, + avatarUrl = customAvatarUrl, usesPrimaryAddons = usesPrimaryAddons, ) } @@ -330,6 +374,7 @@ private fun ProfileIdentityCard( onNameChange: (String) -> Unit, onUsesPrimaryAddonsChange: (Boolean) -> Unit, selectedAvatar: AvatarCatalogItem?, + customAvatarUrl: String?, accentColor: Color, hasAvatarChoices: Boolean, ) { @@ -345,16 +390,31 @@ private fun ProfileIdentityCard( .size(88.dp) .clip(CircleShape) .background( - if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f), + if (selectedAvatar != null || customAvatarUrl != null) { + accentColor + } else { + accentColor.copy(alpha = 0.18f) + }, ) .border( width = 2.dp, - color = if (selectedAvatar == null) accentColor.copy(alpha = 0.35f) else Color.Transparent, + color = if (selectedAvatar == null && customAvatarUrl == null) { + accentColor.copy(alpha = 0.35f) + } else { + Color.Transparent + }, shape = CircleShape, ), contentAlignment = Alignment.Center, ) { - if (selectedAvatar != null) { + if (customAvatarUrl != null) { + AsyncImage( + model = customAvatarUrl, + contentDescription = name, + modifier = Modifier.size(88.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else if (selectedAvatar != null) { AsyncImage( model = avatarStorageUrl(selectedAvatar.storagePath), contentDescription = selectedAvatar.displayName, @@ -410,6 +470,7 @@ private fun ProfileIdentityCard( ) Text( text = when { + customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected) selectedAvatar != null -> stringResource( Res.string.profile_avatar_selected, selectedAvatar.displayName, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index 3e91429f..f36aee81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -12,6 +12,7 @@ data class NuvioProfile( val name: String = "", @SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5", @SerialName("avatar_id") val avatarId: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, @SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("pin_enabled") val pinEnabled: Boolean = false, @@ -28,6 +29,7 @@ data class ProfilePushPayload( @SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("avatar_id") val avatarId: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, ) @Serializable @@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf( fun avatarStorageUrl(storagePath: String): String = "${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath" + +fun normalizedAvatarUrl(url: String?): String? = + url?.trim()?.takeIf { it.isValidAvatarUrl() } + +fun String.isValidAvatarUrl(): Boolean { + val value = trim() + return value.length <= 2048 && + !value.any { it.isWhitespace() } && + (value.startsWith("https://") || value.startsWith("http://")) +} + +fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): String? = + normalizedAvatarUrl(profile.avatarUrl) + ?: avatar + ?.storagePath + ?.takeIf { it.isNotBlank() } + ?.let(::avatarStorageUrl) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 01904938..0cb6cc27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -179,6 +179,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val existing = _state.value.profiles @@ -192,6 +193,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } + ProfilePushPayload( profileIndex = nextIndex, @@ -199,6 +201,7 @@ object ProfileRepository { avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, avatarId = avatarId, + avatarUrl = avatarUrl, ) pushProfiles(allPayloads) @@ -209,6 +212,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val allPayloads = _state.value.profiles.map { profile -> @@ -218,7 +222,8 @@ object ProfileRepository { name = name, avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, - avatarId = avatarId ?: profile.avatarId, + avatarId = avatarId, + avatarUrl = avatarUrl, ) } else { ProfilePushPayload( @@ -228,6 +233,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } } @@ -357,6 +363,7 @@ object ProfileRepository { name = p.name, avatarColorHex = p.avatarColorHex, avatarId = p.avatarId, + avatarUrl = p.avatarUrl, usesPrimaryAddons = p.usesPrimaryAddons, usesPrimaryPlugins = p.usesPrimaryPlugins, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 3d487c02..195ba674 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -304,6 +304,9 @@ private fun ProfileAvatarCard( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } val animAlpha = remember { Animatable(0f) } val animScale = remember { Animatable(0.85f) } @@ -342,8 +345,8 @@ private fun ProfileAvatarCard( modifier = Modifier.size(110.dp), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { - val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor Box( modifier = Modifier .size(110.dp) @@ -364,15 +367,15 @@ private fun ProfileAvatarCard( }, ) .then( - if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape) + if (avatarImageUrl == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape) else Modifier, ), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), - contentDescription = avatarItem.displayName, + model = avatarImageUrl, + contentDescription = avatarItem?.displayName ?: profile.name, modifier = Modifier.size(100.dp).clip(CircleShape), contentScale = ContentScale.Crop, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index d6a77b3f..cecd6273 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -341,6 +341,9 @@ private fun PopupProfileBubble( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } // Per-item entrance animation val itemAlpha = remember { Animatable(0f) } @@ -393,8 +396,8 @@ private fun PopupProfileBubble( .size(48.dp) .clip(CircleShape) .background( - if (avatarItem != null) { - avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor } else { avatarColor.copy(alpha = 0.15f) }, @@ -411,7 +414,7 @@ private fun PopupProfileBubble( avatarColor.copy(alpha = 0.6f), CircleShape, ) - avatarItem == null -> Modifier.border( + avatarImageUrl == null -> Modifier.border( 1.5.dp, avatarColor.copy(alpha = 0.3f), CircleShape, @@ -421,9 +424,9 @@ private fun PopupProfileBubble( ), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), + model = avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(48.dp).clip(CircleShape), contentScale = ContentScale.Crop, @@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } val borderColor = if (selected) { MaterialTheme.colorScheme.primary @@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar( .size(size.dp) .clip(CircleShape) .background( - if (avatarItem != null) { - avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor } else { avatarColor.copy(alpha = 0.15f) }, @@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar( .border(1.5.dp, borderColor, CircleShape), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), + model = avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(size.dp).clip(CircleShape), contentScale = ContentScale.Crop,