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