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/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/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 7f970d32..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 @@ -16,12 +16,14 @@ 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", "nuvio_episode_release_notifications", "nuvio_episode_release_notifications_platform", "nuvio_watch_progress", + "nuvio_collections", "nuvio_plugins", ) 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..c7c556c5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.android.kt @@ -0,0 +1,18 @@ +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 + +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/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/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..e8b01632 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. Instant profile switching from the tab bar is unavailable while this is on. Tune card width and corner radius. DISPLAY HOME @@ -475,6 +477,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 @@ -506,6 +510,10 @@ 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 + Include upcoming episodes in Continue Watching before they air. + Show Unaired Next Up Episodes Poster Card Style ON LAUNCH UP NEXT BEHAVIOR @@ -518,6 +526,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 + Prefer episode thumbnails when available. + Prefer Episode Thumbnails in Continue Watching HOME SOURCES Install, remove, refresh, and sort your content sources. @@ -557,6 +567,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 @@ -783,6 +795,28 @@ 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 + 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 @@ -973,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 f9e85f6c..3eebbdac 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 @@ -60,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 @@ -92,6 +95,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 @@ -122,11 +129,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.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen @@ -152,8 +161,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 @@ -262,6 +269,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, @@ -295,13 +316,36 @@ fun App() { LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() + AvatarRepository.fetchAvatars() } 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, + profileState.activeProfile?.avatarUrl, + 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 = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) }, + avatarBackgroundColorHex = avatarItem?.bgColor, + ) + } + var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } @@ -468,6 +512,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) } @@ -486,10 +535,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 +553,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) } @@ -521,6 +566,42 @@ private fun MainAppContent( .sorted() } + LaunchedEffect(nativeRequestedTab) { + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + selectedTab = nativeRequestedTab.toAppScreenTab() + } + } + + LaunchedEffect(selectedTab) { + 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() @@ -892,6 +973,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 @@ -906,7 +989,7 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout) { + if (!isTabletLayout && !useNativeBottomTabs) { NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, @@ -942,58 +1025,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 }, @@ -1664,12 +1751,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 +1764,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/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..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 @@ -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 @@ -150,12 +152,14 @@ 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" }, 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 +203,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 +235,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 +252,7 @@ object ProfileSettingsSync { MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -257,12 +266,14 @@ 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}", "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 +294,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/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt new file mode 100644 index 00000000..d7422533 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -0,0 +1,78 @@ +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 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) { + 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) + +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/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/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/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/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/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/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/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d5561d45..9bc72c0c 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 @@ -79,6 +79,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 @@ -165,6 +166,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 && @@ -192,6 +194,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 @@ -274,39 +300,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] @@ -637,6 +663,7 @@ fun MetaDetailsScreen( onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, showManualPlayOption = showManualPlayOption, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeNumber = seriesAction?.episodeNumber, @@ -653,6 +680,7 @@ fun MetaDetailsScreen( commentsCurrentPage = commentsCurrentPage, commentsPageCount = commentsPageCount, commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, onRetryComments = { detailsScope.launch { isCommentsLoading = true @@ -687,6 +715,7 @@ fun MetaDetailsScreen( onTrailerClick = resolveTrailer, progressByVideoId = watchProgressUiState.byVideoId, watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onOpenMeta = onOpenMeta, @@ -933,6 +962,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( @@ -944,6 +997,7 @@ private fun ConfiguredMetaSections( onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, showManualPlayOption: Boolean, preferredEpisodeSeasonNumber: Int?, preferredEpisodeNumber: Int?, @@ -960,12 +1014,14 @@ private fun ConfiguredMetaSections( commentsCurrentPage: Int, commentsPageCount: Int, commentsError: String?, + episodeImdbRatings: Map, Double>, onRetryComments: () -> Unit, onLoadMoreComments: () -> Unit, onCommentClick: (TraktCommentReview) -> Unit, onTrailerClick: (MetaTrailer) -> Unit, progressByVideoId: Map, watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, onEpisodeClick: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, @@ -1008,6 +1064,7 @@ private fun ConfiguredMetaSections( onPlayClick = onPrimaryPlayClick, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, ) } MetaScreenSectionKey.OVERVIEW -> { @@ -1057,6 +1114,8 @@ 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/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/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/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/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/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 485c729a..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 @@ -45,6 +47,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 @@ -76,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") @@ -90,6 +96,8 @@ 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, ) { @@ -276,6 +284,8 @@ fun DetailSeriesContent( watchedKeys = watchedKeys, fallbackImage = meta.background ?: meta.poster, progressByVideoId = progressByVideoId, + episodeRatings = episodeRatings, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, preferredEpisodeNumber = preferredEpisodeNumber, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, @@ -295,13 +305,15 @@ fun DetailSeriesContent( video = episode, fallbackImage = meta.background ?: meta.poster, progressEntry = progressByVideoId[episodeVideoId], - isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || + imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] }, + 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 +565,8 @@ private fun EpisodeHorizontalRow( watchedKeys: Set, fallbackImage: String?, progressByVideoId: Map, + episodeRatings: Map, Double>, + blurUnwatchedEpisodes: Boolean, preferredEpisodeNumber: Int? = null, onEpisodeClick: ((MetaVideo) -> Unit)?, onEpisodeLongPress: ((MetaVideo) -> Unit)?, @@ -597,13 +611,15 @@ private fun EpisodeHorizontalRow( video = episode, fallbackImage = fallbackImage, progressEntry = progressByVideoId[episodeVideoId], - isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || + imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] }, + 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) }, @@ -618,12 +634,17 @@ private fun EpisodeHorizontalCard( video: MetaVideo, fallbackImage: String?, progressEntry: WatchProgressEntry?, + imdbRating: Double?, isWatched: Boolean, + blurUnwatchedEpisodes: Boolean, metrics: EpisodeHorizontalCardMetrics, onClick: (() -> Unit)? = null, 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) @@ -642,11 +663,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, ) } @@ -666,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 @@ -709,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( @@ -734,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, + ) + } } } } @@ -793,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 @@ -815,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( @@ -834,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( @@ -853,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( @@ -873,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, ) } } @@ -882,19 +923,83 @@ 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, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, 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() @@ -923,11 +1028,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 { @@ -938,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, @@ -991,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()) { @@ -1211,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" +} 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( 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/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 82659478..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 @@ -29,6 +31,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 @@ -36,6 +42,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 @@ -45,6 +52,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 @@ -87,6 +95,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 +126,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) { @@ -144,6 +170,9 @@ fun HomeScreen( ) } } + val completedSeriesContentIds = remember(completedSeriesCandidates) { + completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } + } val visibleContinueWatchingEntries = remember( effectiveWatchProgressEntries, latestCompletedBySeries, @@ -159,11 +188,28 @@ 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, + continueWatchingPreferences.showUnairedNextUp, + 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 } + if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { + return@mapNotNull null + } val item = cached.toContinueWatchingItem() ?: return@mapNotNull null cached.contentId to (cached.sortTimestamp to item) }.toMap() @@ -242,7 +288,11 @@ fun HomeScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - LaunchedEffect(completedSeriesCandidates, metaProviderKey) { + LaunchedEffect( + completedSeriesCandidates, + metaProviderKey, + continueWatchingPreferences.showUnairedNextUp, + ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() return@LaunchedEffect @@ -263,7 +313,7 @@ fun HomeScreen( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, - showUnairedNextUp = isTraktAuthenticated, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) ?: return@withPermit null val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) @@ -291,6 +341,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, @@ -353,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, @@ -366,6 +427,7 @@ fun HomeScreen( hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), continueWatchingStyle = continueWatchingPreferences.style, continueWatchingLayout = continueWatchingLayout, + bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight, ) } @@ -409,6 +471,8 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -432,6 +496,8 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -474,6 +540,8 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -525,7 +593,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, @@ -533,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( @@ -548,6 +632,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 -> @@ -560,7 +651,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, @@ -574,7 +666,7 @@ 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) } @@ -632,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, + released = released, isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -698,5 +791,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/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/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index b84db29a..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 @@ -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 @@ -50,10 +51,44 @@ 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, + poster, + background, + imageUrl, + ) + isNextUp -> firstNonBlank( + poster, + background, + episodeThumbnail, + imageUrl, + ) + useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + poster, + background, + imageUrl, + ) + else -> firstNonBlank( + poster, + background, + 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, layout: ContinueWatchingLayout? = null, @@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, layout = layout, @@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), @@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection( private fun HomeContinueWatchingSectionContent( items: List, style: ContinueWatchingSectionStyle, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, modifier: Modifier, sectionPadding: Dp, layout: ContinueWatchingLayout, @@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( item = item, layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, ) ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( item = item, layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, ) @@ -273,6 +318,8 @@ private fun PosterCardPreview() { private fun ContinueWatchingWideCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard( onLongClick = onLongClick, ), ) { - val artworkUrl = item.poster ?: item.background ?: item.imageUrl + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) ArtworkPanel( imageUrl = artworkUrl, width = layout.widePosterStripWidth, + blurred = shouldBlurArtwork, modifier = Modifier.fillMaxHeight(), ) Column( @@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard( private fun ContinueWatchingPosterCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard( ) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { - val imageUrl = item.poster ?: item.imageUrl + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) 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 +543,7 @@ private fun ContinueWatchingPosterCard( private fun ArtworkPanel( imageUrl: String?, width: Dp, + blurred: Boolean = false, modifier: Modifier = Modifier, ) { Box( @@ -500,7 +555,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/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/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/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, ) } } 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/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 07a9d9c6..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 @@ -20,6 +20,7 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -135,6 +136,7 @@ object ProfileRepository { ) persist() WatchedRepository.onProfileChanged(profileIndex) + TraktSettingsRepository.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) @@ -177,6 +179,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val existing = _state.value.profiles @@ -190,6 +193,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } + ProfilePushPayload( profileIndex = nextIndex, @@ -197,6 +201,7 @@ object ProfileRepository { avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, avatarId = avatarId, + avatarUrl = avatarUrl, ) pushProfiles(allPayloads) @@ -207,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 -> @@ -216,7 +222,8 @@ object ProfileRepository { name = name, avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, - avatarId = avatarId ?: profile.avatarId, + avatarId = avatarId, + avatarUrl = avatarUrl, ) } else { ProfilePushPayload( @@ -226,6 +233,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } } @@ -355,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, 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/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/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 34ab403c..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 @@ -28,6 +28,10 @@ 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_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 @@ -40,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 @@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent( isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean, + showUnairedNextUp: Boolean, + blurNextUp: Boolean, showResumePromptOnLaunch: Boolean, ) { item { @@ -84,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), @@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent( isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + 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::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/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/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..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, ) } @@ -127,6 +131,9 @@ 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 6c80adb8..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 @@ -56,6 +58,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 @@ -92,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() @@ -109,6 +117,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 @@ -129,6 +141,7 @@ fun SettingsScreen( } } val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val metaScreenSettingsUiState by remember { @@ -184,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, @@ -191,7 +207,9 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, + traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -224,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, @@ -231,7 +252,9 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, + traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -274,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, @@ -281,7 +307,9 @@ private fun MobileSettingsScreen( mdbListSettings: MdbListSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, + traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -353,6 +381,9 @@ private fun MobileSettingsScreen( onThemeSelected = onThemeSelected, amoledEnabled = amoledEnabled, onAmoledToggle = onAmoledToggle, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, onContinueWatchingClick = onContinueWatchingClick, @@ -367,6 +398,9 @@ private fun MobileSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( @@ -387,6 +421,7 @@ private fun MobileSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = false, heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, items = homescreenItems, ) SettingsPage.MetaScreen -> metaScreenSettingsContent( @@ -409,6 +444,7 @@ private fun MobileSettingsScreen( SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, + settingsUiState = traktSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) @@ -439,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, @@ -446,7 +485,9 @@ private fun TabletSettingsScreen( mdbListSettings: MdbListSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, + traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -519,6 +560,7 @@ private fun TabletSettingsScreen( saveableStateHolder.SaveableStateProvider(page.name) { val listState = rememberLazyListState() + val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -526,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), ) { @@ -589,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) }, @@ -603,6 +648,9 @@ private fun TabletSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( @@ -623,6 +671,7 @@ private fun TabletSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, items = homescreenItems, ) SettingsPage.MetaScreen -> metaScreenSettingsContent( @@ -645,6 +694,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/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 863dd04f..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 @@ -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,9 @@ object ThemeSettingsRepository { hasLoaded = false _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false + _liquidGlassNativeTabBarEnabled.value = false + NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) + NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.ENGLISH } @@ -46,7 +53,11 @@ object ThemeSettingsRepository { AppTheme.WHITE } _selectedTheme.value = theme + NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex()) _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 @@ -57,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) { @@ -66,6 +78,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 @@ -74,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/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/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 82130875..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 @@ -1,31 +1,57 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.traktBrandPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_playback_dialog_close import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect import nuvio.composeapp.generated.resources.settings_trakt_authentication import nuvio.composeapp.generated.resources.settings_trakt_comments @@ -42,11 +68,34 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials import nuvio.composeapp.generated.resources.settings_trakt_open_login import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import nuvio.composeapp.generated.resources.trakt_all_history +import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle +import nuvio.composeapp.generated.resources.trakt_continue_watching_window +import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle +import nuvio.composeapp.generated.resources.trakt_cw_window_title +import nuvio.composeapp.generated.resources.trakt_days_format +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected +import nuvio.composeapp.generated.resources.trakt_library_source_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_title +import nuvio.composeapp.generated.resources.trakt_library_source_trakt +import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt +import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.traktSettingsContent( isTablet: Boolean, uiState: TraktAuthUiState, + settingsUiState: TraktSettingsUiState, commentsEnabled: Boolean, onCommentsEnabledChange: (Boolean) -> Unit, ) { @@ -77,12 +126,414 @@ internal fun LazyListScope.traktSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - SettingsSwitchRow( - title = stringResource(Res.string.settings_trakt_comments), - description = stringResource(Res.string.settings_trakt_comments_description), - checked = commentsEnabled, + TraktFeatureRows( isTablet = isTablet, - onCheckedChange = onCommentsEnabledChange, + settingsUiState = settingsUiState, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) + } + } + } + } +} + +@Composable +private fun TraktFeatureRows( + isTablet: Boolean, + settingsUiState: TraktSettingsUiState, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } + var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } + var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } + var statusMessage by rememberSaveable { mutableStateOf(null) } + + val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) + val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) + val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) + val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) + val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) + val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) + val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) + + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_library_source_title), + description = stringResource(Res.string.trakt_library_source_subtitle), + value = librarySourceValue, + isTablet = isTablet, + onClick = { showLibrarySourceDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_watch_progress_title), + description = stringResource(Res.string.trakt_watch_progress_subtitle), + value = watchProgressValue, + isTablet = isTablet, + onClick = { showWatchProgressDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_continue_watching_window), + description = stringResource(Res.string.trakt_continue_watching_subtitle), + value = continueWatchingWindowValue, + isTablet = isTablet, + onClick = { showContinueWatchingWindowDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_trakt_comments), + description = stringResource(Res.string.settings_trakt_comments_description), + checked = commentsEnabled, + isTablet = isTablet, + onCheckedChange = onCommentsEnabledChange, + ) + statusMessage?.takeIf { it.isNotBlank() }?.let { message -> + SettingsGroupDivider(isTablet = isTablet) + TraktInfoRow( + isTablet = isTablet, + text = message, + ) + } + + if (showLibrarySourceDialog) { + LibrarySourceModeDialog( + selectedSource = settingsUiState.librarySourceMode, + onSourceSelected = { source -> + TraktSettingsRepository.setLibrarySourceMode(source) + statusMessage = if (source == LibrarySourceMode.TRAKT) { + traktLibrarySelectedMessage + } else { + nuvioLibrarySelectedMessage + } + showLibrarySourceDialog = false + }, + onDismiss = { showLibrarySourceDialog = false }, + ) + } + + if (showWatchProgressDialog) { + WatchProgressSourceDialog( + selectedSource = settingsUiState.watchProgressSource, + onSourceSelected = { source -> + TraktSettingsRepository.setWatchProgressSource(source) + statusMessage = if (source == WatchProgressSource.TRAKT) { + traktProgressSelectedMessage + } else { + nuvioProgressSelectedMessage + } + showWatchProgressDialog = false + }, + onDismiss = { showWatchProgressDialog = false }, + ) + } + + if (showContinueWatchingWindowDialog) { + ContinueWatchingWindowDialog( + selectedDaysCap = settingsUiState.continueWatchingDaysCap, + onDaysCapSelected = { days -> + TraktSettingsRepository.setContinueWatchingDaysCap(days) + showContinueWatchingWindowDialog = false + }, + onDismiss = { showContinueWatchingWindowDialog = false }, + ) + } +} + +@Composable +private fun TraktSettingsActionRow( + title: String, + description: String, + value: String, + isTablet: Boolean, + onClick: () -> Unit, +) { + val verticalPadding = if (isTablet) 16.dp else 14.dp + val horizontalPadding = if (isTablet) 20.dp else 16.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun TraktInfoRow( + isTablet: Boolean, + text: String, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 14.dp else 12.dp + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun librarySourceModeLabel(source: LibrarySourceMode): String = + when (source) { + LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) + LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + } + +@Composable +private fun watchProgressSourceLabel(source: WatchProgressSource): String = + when (source) { + WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) + WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + } + +@Composable +private fun continueWatchingDaysCapLabel(daysCap: Int): String { + val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) + return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + stringResource(Res.string.trakt_all_history) + } else { + stringResource(Res.string.trakt_days_format, normalized) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LibrarySourceModeDialog( + selectedSource: LibrarySourceMode, + onSourceSelected: (LibrarySourceMode) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_library_source_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_library_source_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source -> + TraktDialogOption( + label = librarySourceModeLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun WatchProgressSourceDialog( + selectedSource: WatchProgressSource, + onSourceSelected: (WatchProgressSource) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_watch_progress_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source -> + TraktDialogOption( + label = watchProgressSourceLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ContinueWatchingWindowDialog( + selectedDaysCap: Int, + onDaysCapSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap) + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_cw_window_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_cw_window_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TraktContinueWatchingDaysOptions.forEach { days -> + val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) + TraktDialogOption( + label = continueWatchingDaysCapLabel(days), + selected = normalizedDays == normalizedSelected, + onClick = { onDaysCapSelected(days) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun TraktDialogOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, ) } } 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/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( 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/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt new file mode 100644 index 00000000..ee9cccd4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -0,0 +1,166 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0 +const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60 +const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7 +const val TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP = 365 + +val TraktContinueWatchingDaysOptions: List = listOf( + 14, + 30, + TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + 90, + 180, + TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP, + TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, +) + +@Serializable +enum class WatchProgressSource { + TRAKT, + NUVIO_SYNC; + + companion object { + fun fromStorage(value: String?): WatchProgressSource = + entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE + } +} + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE + +data class TraktSettingsUiState( + val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE, +) + +@Serializable +private data class StoredTraktSettings( + val watchProgressSource: String? = null, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + val librarySourceMode: String? = null, +) + +object TraktSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(TraktSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = TraktSettingsUiState() + } + + fun setWatchProgressSource(source: WatchProgressSource) { + ensureLoaded() + if (_uiState.value.watchProgressSource == source) return + _uiState.value = _uiState.value.copy(watchProgressSource = source) + persist() + } + + fun setContinueWatchingDaysCap(days: Int) { + ensureLoaded() + val normalized = normalizeTraktContinueWatchingDaysCap(days) + if (_uiState.value.continueWatchingDaysCap == normalized) return + _uiState.value = _uiState.value.copy(continueWatchingDaysCap = normalized) + persist() + } + + fun setLibrarySourceMode(mode: LibrarySourceMode) { + ensureLoaded() + if (_uiState.value.librarySourceMode == mode) return + _uiState.value = _uiState.value.copy(librarySourceMode = mode) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = TraktSettingsStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = TraktSettingsUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = if (stored != null) { + TraktSettingsUiState( + watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), + continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), + librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode), + ) + } else { + TraktSettingsUiState() + } + } + + private fun persist() { + TraktSettingsStorage.savePayload( + json.encodeToString( + StoredTraktSettings( + watchProgressSource = _uiState.value.watchProgressSource.name, + continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, + librarySourceMode = _uiState.value.librarySourceMode.name, + ), + ), + ) + } +} + +fun normalizeTraktContinueWatchingDaysCap(days: Int): Int = + if (days == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL + } else { + days.coerceIn(TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP, TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP) + } + +fun shouldUseTraktProgress( + isAuthenticated: Boolean, + source: WatchProgressSource, +): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT + +fun effectiveLibrarySourceMode( + isAuthenticated: Boolean, + source: LibrarySourceMode, +): LibrarySourceMode = + if (isAuthenticated && source == LibrarySourceMode.TRAKT) { + LibrarySourceMode.TRAKT + } else { + LibrarySourceMode.LOCAL + } + +fun shouldUseTraktLibrary( + isAuthenticated: Boolean, + source: LibrarySourceMode, +): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT 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/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 5e0eb093..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 @@ -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,12 @@ 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(), val showResumePromptOnLaunch: Boolean = true, ) @@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository { isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean = true, + showUnairedNextUp: Boolean = true, + blurNextUp: Boolean = false, dismissedNextUpKeys: Set, ) { ensureLoaded() @@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository { isVisible = isVisible, style = style, upNextFromFurthestEpisode = upNextFromFurthestEpisode, + useEpisodeThumbnails = useEpisodeThumbnails, + showUnairedNextUp = showUnairedNextUp, + blurNextUp = blurNextUp, dismissedNextUpKeys = dismissedNextUpKeys .map(String::trim) .filter(String::isNotBlank) @@ -79,6 +92,9 @@ 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, ) @@ -105,6 +121,24 @@ 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) + persist() + } + fun addDismissedNextUpKey(key: String) { ensureLoaded() val normalizedKey = key.trim() @@ -139,6 +173,9 @@ 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 12efbd73..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,9 @@ 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, ) @@ -204,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, @@ -241,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 55adcebe..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 @@ -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,14 @@ object WatchProgressRepository { } suspend fun pullFromServer(profileId: Int) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + TraktProgressRepository.ensureLoaded() currentProfileId = profileId - if (TraktAuthRepository.isAuthenticated.value) { + val useTraktProgress = shouldUseTraktProgress() + + if (useTraktProgress) { runCatching { TraktProgressRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } publish() @@ -368,7 +396,6 @@ object WatchProgressRepository { } private fun pushScrobbleToServer(entry: WatchProgressEntry) { - if (shouldUseTraktProgress()) return syncScope.launch { runCatching { val profileId = ProfileRepository.activeProfileId @@ -394,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, ) } @@ -406,7 +434,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/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/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/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 849211a7..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 @@ -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,91 @@ 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( + 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 +186,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/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, + ) +} 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 new file mode 100644 index 00000000..f504fcc8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -0,0 +1,67 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktSettingsRepositoryTest { + + @Test + fun `watch progress source defaults to Trakt for unset or invalid storage`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null)) + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("")) + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("not-a-source")) + } + + @Test + fun `watch progress source restores valid storage values`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT")) + assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) + } + + @Test + fun `library source defaults to Trakt for unset or invalid storage`() { + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(null)) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("")) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("not-a-source")) + } + + @Test + fun `library source restores valid storage values`() { + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT")) + assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL")) + } + + @Test + fun `continue watching cap normalizes finite windows and all history`() { + assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) + assertEquals(7, normalizeTraktContinueWatchingDaysCap(1)) + assertEquals(60, normalizeTraktContinueWatchingDaysCap(60)) + assertEquals(365, normalizeTraktContinueWatchingDaysCap(999)) + } + + @Test + fun `Trakt progress is active only when authenticated and selected`() { + assertFalse(shouldUseTraktProgress(isAuthenticated = false, source = WatchProgressSource.TRAKT)) + assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC)) + assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT)) + } + + @Test + fun `effective library source uses Trakt only when authenticated and selected`() { + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(isAuthenticated = false, source = LibrarySourceMode.TRAKT), + ) + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.LOCAL), + ) + assertEquals( + LibrarySourceMode.TRAKT, + effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT), + ) + } +} 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, ) } 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..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 @@ -45,6 +45,8 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_audience", "trakt_auth_payload", "trakt_library_payload", + "trakt_settings_payload", + "collections_payload", ) actual fun wipe() { 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..1b72da7c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -0,0 +1,69 @@ +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 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 { + 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() +} + +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/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/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)) + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 2c747aca..965f9e75 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=54 +MARKETING_VERSION=0.1.0 diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 8b736eb9..14f5664a 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -2,8 +2,316 @@ import UIKit import SwiftUI import ComposeApp -final class RootComposeViewController: UIViewController { +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..= 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 + let item = UITabBarItem( + title: tab.title, + image: tab.iconImage, + selectedImage: tab.iconImage + ) + item.tag = tab.tag + return item + } + tabBar.selectedItem = tabBar.items?.first + applyNativeTabBarAppearance() + 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() + applyNativeTabBarAppearance() + 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 }) + } + + private func applyNativeTabBarAppearance() { + let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ?? + UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) + let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1) + + refreshProfileAvatarImageIfNeeded() + updateNativeTabImages(accent: accent) + + tabBar.tintColor = accent + tabBar.unselectedItemTintColor = unselected + + let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance + appearance.stackedLayoutAppearance.normal.iconColor = unselected + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] + appearance.stackedLayoutAppearance.selected.iconColor = accent + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] + appearance.inlineLayoutAppearance.normal.iconColor = unselected + appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] + appearance.inlineLayoutAppearance.selected.iconColor = accent + appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] + appearance.compactInlineLayoutAppearance.normal.iconColor = unselected + appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] + appearance.compactInlineLayoutAppearance.selected.iconColor = accent + appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] + tabBar.standardAppearance = appearance + tabBar.scrollEdgeAppearance = appearance + } + + private func updateNativeTabImages(accent: UIColor) { + tabBar.items?.forEach { item in + guard let tab = NativeTab(tag: item.tag) else { return } + item.image = nativeTabImage(for: tab, selected: false, accent: accent) + item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent) + } + } + + private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> 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 {