Merge branch 'cmp-rewrite' into trailer-fullscreen-player

This commit is contained in:
Marius Butz 2026-05-07 14:10:41 +02:00
commit a6d5440945
84 changed files with 3991 additions and 443 deletions

View file

@ -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(

View file

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

View file

@ -16,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",
)

View file

@ -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

View file

@ -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)
}

View file

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

View file

@ -449,6 +449,8 @@
<string name="settings_appearance_app_language">App Language</string>
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
<string name="settings_appearance_section_display">DISPLAY</string>
<string name="settings_appearance_section_home">HOME</string>
@ -475,6 +477,8 @@
<string name="settings_homescreen_selected_count">%1$d of %2$d selected</string>
<string name="settings_homescreen_show_hero">Show Hero Section</string>
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
<string name="settings_homescreen_visible">Visible</string>
@ -506,6 +510,10 @@
<string name="settings_show_secret">Show value</string>
<string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
<string name="settings_continue_watching_blur_next_up_description">Blur next episode thumbnails in Continue Watching to avoid spoilers.</string>
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
@ -518,6 +526,8 @@
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Prefer episode thumbnails when available.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Prefer Episode Thumbnails in Continue Watching</string>
<string name="settings_content_discovery_section_home">HOME</string>
<string name="settings_content_discovery_section_sources">SOURCES</string>
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
@ -557,6 +567,8 @@
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
<string name="settings_meta_episodes">Episodes</string>
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
<string name="settings_meta_group_label">Group %1$d</string>
<string name="settings_meta_more_like_this">More like this</string>
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
@ -783,6 +795,28 @@
<string name="settings_trakt_open_login">Open Trakt Login</string>
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
<string name="trakt_library_source_title">Library Source</string>
<string name="trakt_library_source_subtitle">Choose which library to use for saving and viewing your collection</string>
<string name="trakt_library_source_dialog_title">Library Source</string>
<string name="trakt_library_source_dialog_subtitle">Choose where to save and manage your library items</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Nuvio Library</string>
<string name="trakt_library_source_trakt_selected">Trakt library selected</string>
<string name="trakt_library_source_nuvio_selected">Nuvio library selected</string>
<string name="trakt_watch_progress_title">Watch Progress</string>
<string name="trakt_watch_progress_subtitle">Choose which progress source powers resume and continue watching</string>
<string name="trakt_watch_progress_dialog_title">Watch Progress</string>
<string name="trakt_watch_progress_dialog_subtitle">Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active.</string>
<string name="trakt_watch_progress_source_trakt">Trakt</string>
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
<string name="trakt_continue_watching_window">Continue Watching Window</string>
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
<string name="trakt_cw_window_title">Continue Watching Window</string>
<string name="trakt_cw_window_subtitle">Choose how much Trakt activity should appear in continue watching.</string>
<string name="trakt_all_history">All history</string>
<string name="trakt_days_format">%1$d days</string>
<string name="source_audience_score">Audience Score</string>
<string name="source_imdb">IMDb</string>
<string name="source_letterboxd">Letterboxd</string>
@ -973,9 +1007,14 @@
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
<string name="profile_avatar_options_pending">Avatar options will appear here when the catalog loads.</string>
<string name="profile_avatar_selected">Avatar: %1$s</string>
<string name="profile_avatar_url_invalid">Enter a valid http:// or https:// image URL.</string>
<string name="profile_choose_avatar">Choose an avatar</string>
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
<string name="profile_create_profile">Create Profile</string>
<string name="profile_custom_avatar_selected">Custom avatar URL selected.</string>
<string name="profile_custom_avatar_url">Custom avatar URL</string>
<string name="profile_custom_avatar_url_description">Paste an image link, or leave this empty to use the built-in avatar catalog.</string>
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="profile_delete_confirm_message">All data for &quot;%1$s&quot; will be permanently deleted.</string>
<string name="profile_delete_title">Delete Profile</string>
<string name="profile_edit_add_title">Add Profile</string>

View file

@ -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<NuvioProfile?>(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<MetaPreview?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(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<TabsRoute>() == 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)

View file

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

View file

@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktSettingsStorage
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest
@ -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(),
)

View file

@ -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<NativeNavigationTab> = _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?,
)

View file

@ -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
}

View file

@ -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,

View file

@ -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,
)
}

View file

@ -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 {

View file

@ -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_")

View file

@ -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<Pair<Int, Int>, 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<String, CacheEntry>()
private val inFlight = mutableMapOf<String, Deferred<Map<Pair<Int, Int>, Double>>>()
suspend fun getEpisodeRatings(
imdbId: String?,
tmdbId: Int?,
): Map<Pair<Int, Int>, 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<Pair<Int, Int>, 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<SeriesGraphSeasonRatingsDto>): Map<Pair<Int, Int>, 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
}

View file

@ -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<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList()

View file

@ -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<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, 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<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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,
)

View file

@ -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,
),
),
)

View file

@ -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<SeriesGraphSeasonRatingsDto> =
requestSeasonRatings(
baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL,
showId = tmdbId.toString(),
)
}
internal object ImdbTapframeApi {
suspend fun getSeasonRatings(imdbId: String): List<SeriesGraphSeasonRatingsDto> =
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<SeriesGraphEpisodeRatingDto>? = null,
)
private val seriesGraphLog = Logger.withTag("SeriesGraphApi")
private val seriesGraphJson = Json { ignoreUnknownKeys = true }
private suspend fun requestSeasonRatings(
baseUrl: String,
showId: String,
): List<SeriesGraphSeasonRatingsDto> {
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<List<SeriesGraphSeasonRatingsDto>>(response.body)
}.onFailure { error ->
seriesGraphLog.w(error) { "Season ratings request failed for $showId" }
}.getOrDefault(emptyList())
}

View file

@ -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 =

View file

@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -44,6 +41,7 @@ fun DetailActionButtons(
onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {},
onSaveLongClick: (() -> Unit)? = null,
) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
@ -96,35 +94,49 @@ fun DetailActionButtons(
}
}
OutlinedButton(
onClick = onSaveClick,
Surface(
modifier = rowButtonModifier.height(50.dp),
shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
contentColor = MaterialTheme.colorScheme.onSurface,
) {
if (isSaved) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
} else {
Icon(
painter = libraryAddPainter,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurface,
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onSaveClick,
onLongClick = onSaveLongClick,
role = Role.Button,
)
.height(50.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isSaved) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
} else {
Icon(
painter = libraryAddPainter,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = saveLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = saveLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -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<String, WatchProgressEntry> = emptyMap(),
watchedKeys: Set<String> = emptySet(),
episodeRatings: Map<Pair<Int, Int>, 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<String>,
fallbackImage: String?,
progressByVideoId: Map<String, WatchProgressEntry>,
episodeRatings: Map<Pair<Int, Int>, 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<Int, Int>? {
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"
}

View file

@ -98,3 +98,13 @@ enum class DownloadEnqueueResult {
}
}
}
internal fun List<DownloadItem>.sortedForSeriesDownloads(): List<DownloadItem> =
sortedWith(downloadSeriesEpisodeComparator)
internal val downloadSeriesEpisodeComparator: Comparator<DownloadItem> =
compareBy<DownloadItem> { 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 }

View file

@ -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<DownloadItem> { 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(

View file

@ -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() }

View file

@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<HomeCatalogSettingsItem> = 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<String, HomeCatalogPreference>,
)
@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference(
@Serializable
private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<StoredHomeCatalogPreference> = emptyList(),
)
@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository {
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = 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()

View file

@ -41,6 +41,7 @@ data class SyncCatalogItem(
@Serializable
data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
val items: List<SyncCatalogItem> = 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)

View file

@ -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)

View file

@ -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<Map<String, Pair<Long, ContinueWatchingItem>>>(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<WatchProgressEntry>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<WatchProgressEntry> {
if (!isTraktProgressActive) return entries
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
}
private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float,
@ -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<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
): List<ContinueWatchingItem> {
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,
)
}

View file

@ -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<MetaPreview>.filterReleasedItems(todayIsoDate: String): List<MetaPreview> =
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)

View file

@ -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<ContinueWatchingItem>,
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<ContinueWatchingItem>,
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,
)
}

View file

@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktLibraryRepository
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktListType
import com.nuvio.app.features.trakt.TraktMembershipChanges
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -65,12 +72,28 @@ object LibraryRepository {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) {
TraktLibraryRepository.preloadListTabsAsync()
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
}
}
publish()
}
}
syncScope.launch {
TraktSettingsRepository.uiState
.map { it.librarySourceMode }
.distinctUntilChanged()
.collectLatest { source ->
if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
TraktLibraryRepository.preloadListTabsAsync()
publish()
refreshTraktLibraryAsync()
} else {
publish()
}
}
}
syncScope.launch {
TraktLibraryRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) {
@ -82,23 +105,29 @@ object LibraryRepository {
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktLibraryRepository.ensureLoaded()
if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
refreshTraktLibraryAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync()
}
}
}
fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return
TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId)
TraktAuthRepository.onProfileChanged()
TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
refreshTraktLibraryAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync()
}
}
}
@ -130,7 +159,7 @@ object LibraryRepository {
suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId
if (TraktAuthRepository.isAuthenticated.value) {
if (isTraktLibrarySourceActive()) {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
publish()
@ -157,7 +186,7 @@ object LibraryRepository {
fun toggleSaved(item: LibraryItem) {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
if (isTraktLibrarySourceActive()) {
syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
@ -175,7 +204,6 @@ object LibraryRepository {
fun save(item: LibraryItem) {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) return
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
publish()
persist()
@ -184,7 +212,6 @@ object LibraryRepository {
fun remove(id: String) {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) return
if (itemsById.remove(id) != null) {
publish()
persist()
@ -195,7 +222,7 @@ object LibraryRepository {
fun isSaved(id: String, type: String? = null): Boolean {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
if (isTraktLibrarySourceActive()) {
if (type != null) {
return TraktLibraryRepository.isInAnyList(id, type)
}
@ -212,46 +239,65 @@ object LibraryRepository {
fun savedItem(id: String): LibraryItem? {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
if (isTraktLibrarySourceActive()) {
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
}
return itemsById[id]
}
fun traktListTabs() = TraktLibraryRepository.currentListTabs()
fun libraryListTabs(): List<TraktListTab> {
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.currentListTabs()
} else {
emptyList()
}
return libraryTabsWithLocal(traktTabs)
}
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
}
val inLocal = itemsById.containsKey(item.id)
return mapOf(LOCAL_LIST_KEY to inLocal)
if (TraktAuthRepository.isAuthenticated.value) {
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
return libraryMembershipWithLocal(
inLocal = inLocal,
traktMembership = traktMembership,
)
}
return libraryMembershipWithLocal(inLocal = inLocal)
}
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.applyMembershipChanges(
item = item,
changes = TraktMembershipChanges(desiredMembership = desiredMembership),
)
publish()
return
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
val currentlyInLocal = itemsById.containsKey(item.id)
if (localDesired != currentlyInLocal) {
if (localDesired) {
save(item)
} else {
remove(item.id)
}
}
val shouldBeSaved = desiredMembership.values.any { it }
if (shouldBeSaved) {
save(item)
if (TraktAuthRepository.isAuthenticated.value) {
val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
if (traktMembership.isNotEmpty()) {
TraktLibraryRepository.applyMembershipChanges(
item = item,
changes = TraktMembershipChanges(desiredMembership = traktMembership),
)
}
publish()
} else {
remove(item.id)
publish()
}
}
private fun pushToServer() {
syncScope.launch {
if (TraktAuthRepository.isAuthenticated.value) return@launch
runCatching {
val profileId = ProfileRepository.activeProfileId
val syncItems = itemsById.values.map { it.toSyncItem() }
@ -267,7 +313,7 @@ object LibraryRepository {
}
private fun publish() {
if (TraktAuthRepository.isAuthenticated.value) {
if (isTraktLibrarySourceActive()) {
val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty()
@ -334,9 +380,42 @@ object LibraryRepository {
publish()
}
}
private fun selectedLibrarySourceMode(): LibrarySourceMode {
TraktSettingsRepository.ensureLoaded()
return TraktSettingsRepository.uiState.value.librarySourceMode
}
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
resolveEffectiveLibrarySourceMode(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = selectedLibrarySourceMode(),
)
private fun isTraktLibrarySourceActive(): Boolean =
effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
}
private const val LOCAL_LIST_KEY = "local"
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library"
internal fun localLibraryListTab(): TraktListTab =
TraktListTab(
key = LOCAL_LIBRARY_LIST_KEY,
title = LOCAL_LIBRARY_LIST_TITLE,
type = TraktListType.WATCHLIST,
)
internal fun libraryTabsWithLocal(traktTabs: List<TraktListTab>): List<TraktListTab> =
listOf(localLibraryListTab()) + traktTabs
internal fun libraryMembershipWithLocal(
inLocal: Boolean,
traktMembership: Map<String, Boolean> = emptyMap(),
): Map<String, Boolean> =
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
putAll(traktMembership)
}
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId,

View file

@ -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(

View file

@ -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,
)
}
}

View file

@ -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<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
currentSeason: Int?,
currentEpisode: Int?,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
currentSeason: Int?,
currentEpisode: Int?,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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,
)
}

View file

@ -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,
),

View file

@ -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,

View file

@ -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)

View file

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

View file

@ -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,
)

View file

@ -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,

View file

@ -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<DiscoverCatalogOption> = emptyList()
private var lastDiscoverHideUnreleasedContent: Boolean? = null
fun search(query: String, addons: List<ManagedAddon>) {
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 ?: "<all>"} 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,

View file

@ -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

View file

@ -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),

View file

@ -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,
)
}
}
}
}

View file

@ -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<HomeCatalogSettingsItem>,
) {
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,
)
}
}
}

View file

@ -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) },
)
}
}
}

View file

@ -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,
)
}

View file

@ -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<HomeCatalogSettingsItem>,
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<HomeCatalogSettingsItem>,
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,
)

View file

@ -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<Boolean> = _amoledEnabled.asStateFlow()
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
val selectedAppLanguage: StateFlow<AppLanguage> = _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"
}

View file

@ -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)

View file

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

View file

@ -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(),
)
}

View file

@ -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<TraktListTab> = 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<String, String>): List<TraktListTab> {
val watchlistTabs = listOf(
TraktListTab(

View file

@ -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<String, String>,
): List<WatchProgressEntry> = 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<List<TraktWatchedShowItem>>(payload)
watchedShows
.mapNotNull { item ->
mapWatchedShowSeed(
item = item,
useFurthestEpisode = useFurthestEpisode,
)
}
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
}
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
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<WatchProgressEntry>,
hydrated: List<WatchProgressEntry>,
@ -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<TraktWatchedShowEpisodeSeed>(
{ it.season },
{ it.episode },
{ it.watchedAt },
)
} else {
compareBy<TraktWatchedShowEpisodeSeed>(
{ 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<TraktWatchedShowSeason>? = null,
)
@Serializable
private data class TraktWatchedShowSeason(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktWatchedShowEpisode>? = 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,

View file

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

View file

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

View file

@ -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

View file

@ -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<String, WatchedItem> = 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<StoredWatchedPayload>(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<WatchedItem>,
) {
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<WatchedItem>,
) {
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,
)

View file

@ -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<WatchProgressEntry>,
@Suppress("UNUSED_PARAMETER")
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
): List<WatchProgressEntry> {
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<WatchProgressEntry> = 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),
)

View file

@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
return result.decodeList<WatchProgressSyncEntry>().map { entry ->
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
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(

View file

@ -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 {

View file

@ -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()

View file

@ -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,

View file

@ -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<String> = 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<String>,
) {
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,
),

View file

@ -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<String> = 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,

View file

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

View file

@ -67,15 +67,50 @@ internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): Watc
internal fun List<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> {
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(

View file

@ -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)
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import kotlin.test.Test
import kotlin.test.assertEquals
@ -60,6 +61,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,
)
}
private companion object {
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
}
}

View file

@ -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,
)
}

View file

@ -1,6 +1,8 @@
package com.nuvio.app.features.library
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktListType
import kotlin.test.Test
import kotlin.test.assertEquals
@ -37,4 +39,34 @@ class LibraryRepositoryTest {
assertEquals(PosterShape.Poster, preview.posterShape)
assertEquals("banner", preview.banner)
}
@Test
fun `library tabs include local Nuvio library before Trakt tabs`() {
val traktTab = TraktListTab(
key = "trakt:watchlist",
title = "Watchlist",
type = TraktListType.WATCHLIST,
)
val tabs = libraryTabsWithLocal(listOf(traktTab))
assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key })
assertEquals("Nuvio Library", tabs.first().title)
}
@Test
fun `library membership always includes local state before Trakt membership`() {
val membership = libraryMembershipWithLocal(
inLocal = true,
traktMembership = mapOf("trakt:watchlist" to false),
)
assertEquals(
mapOf(
"local" to true,
"trakt:watchlist" to false,
),
membership,
)
}
}

View file

@ -0,0 +1,67 @@
package com.nuvio.app.features.trakt
import com.nuvio.app.features.library.LibrarySourceMode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TraktSettingsRepositoryTest {
@Test
fun `watch progress source defaults to Trakt for unset or invalid storage`() {
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null))
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(""))
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("not-a-source"))
}
@Test
fun `watch progress source restores valid storage values`() {
assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT"))
assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC"))
}
@Test
fun `library source defaults to Trakt for unset or invalid storage`() {
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(null))
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(""))
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("not-a-source"))
}
@Test
fun `library source restores valid storage values`() {
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT"))
assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL"))
}
@Test
fun `continue watching cap normalizes finite windows and all history`() {
assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0))
assertEquals(7, normalizeTraktContinueWatchingDaysCap(1))
assertEquals(60, normalizeTraktContinueWatchingDaysCap(60))
assertEquals(365, normalizeTraktContinueWatchingDaysCap(999))
}
@Test
fun `Trakt progress is active only when authenticated and selected`() {
assertFalse(shouldUseTraktProgress(isAuthenticated = false, source = WatchProgressSource.TRAKT))
assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC))
assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT))
}
@Test
fun `effective library source uses Trakt only when authenticated and selected`() {
assertEquals(
LibrarySourceMode.LOCAL,
effectiveLibrarySourceMode(isAuthenticated = false, source = LibrarySourceMode.TRAKT),
)
assertEquals(
LibrarySourceMode.LOCAL,
effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.LOCAL),
)
assertEquals(
LibrarySourceMode.TRAKT,
effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT),
)
}
}

View file

@ -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,
),
)
}
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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() {

View file

@ -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)
}

View file

@ -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)
}

View file

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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=50
MARKETING_VERSION=0.1.13
CURRENT_PROJECT_VERSION=54
MARKETING_VERSION=0.1.0

View file

@ -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..<data.endIndex, in: data)
return regex.matches(in: data, range: range).compactMap { match in
guard let tokenRange = Range(match.range, in: data) else { return nil }
let token = String(data[tokenRange])
if token.count == 1, let character = token.first, character.isLetter {
return .command(character)
}
guard let value = Double(token) else { return nil }
return .number(CGFloat(value))
}
}
}
}
final class RootComposeViewController: UIViewController, UITabBarDelegate {
private enum NativeTab: String, CaseIterable {
case home = "Home"
case search = "Search"
case library = "Library"
case settings = "Settings"
var tag: Int {
switch self {
case .home: return 0
case .search: return 1
case .library: return 2
case .settings: return 3
}
}
var title: String {
switch self {
case .home: return "Home"
case .search: return "Search"
case .library: return "Library"
case .settings: return "Profile"
}
}
var iconImage: UIImage {
switch self {
case .home: return NuvioNativeTabIcon.home
case .search: return NuvioNativeTabIcon.search
case .library: return NuvioNativeTabIcon.library
case .settings: return NuvioNativeTabIcon.profileFallback
}
}
init?(tag: Int) {
guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil }
self = tab
}
}
private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
private static let nativeProfileNameKey = "NuvioNativeProfileName"
private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL"
private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
private let contentController: UIViewController
private let tabBar = UITabBar()
private var contentBottomToViewBottom: NSLayoutConstraint?
private var tabBarHeightConstraint: NSLayoutConstraint?
private var userDefaultsObserver: NSObjectProtocol?
private var tabChromeObserver: NSObjectProtocol?
private var profileAvatarImageURL: String?
private var profileAvatarImageTask: URLSessionDataTask?
private var profileAvatarImage: UIImage?
init(contentController: UIViewController) {
self.contentController = contentController
@ -20,17 +328,45 @@ final class RootComposeViewController: UIViewController {
view.backgroundColor = .black
contentController.view.backgroundColor = .black
UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey)
addChild(contentController)
view.addSubview(contentController.view)
contentController.view.translatesAutoresizingMaskIntoConstraints = false
let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
self.contentBottomToViewBottom = bottomToViewBottom
NSLayoutConstraint.activate([
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
bottomToViewBottom,
])
contentController.didMove(toParent: self)
configureNativeTabBar()
installNativeTabObservers()
syncNativeTabChrome(animated: false)
}
deinit {
if let userDefaultsObserver {
NotificationCenter.default.removeObserver(userDefaultsObserver)
}
if let tabChromeObserver {
NotificationCenter.default.removeObserver(tabChromeObserver)
}
profileAvatarImageTask?.cancel()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateTabBarHeight()
}
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
guard let tab = NativeTab(tag: item.tag) else { return }
UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey)
NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue)
}
override var childForHomeIndicatorAutoHidden: UIViewController? {
@ -88,6 +424,210 @@ final class RootComposeViewController: UIViewController {
return nil
}
private var nativeTabsSupported: Bool {
UIDevice.current.userInterfaceIdiom == .phone &&
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
}
private var shouldShowNativeTabBar: Bool {
nativeTabsSupported &&
UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) &&
UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey)
}
private func configureNativeTabBar() {
tabBar.delegate = self
tabBar.translatesAutoresizingMaskIntoConstraints = false
tabBar.items = NativeTab.allCases.map { tab in
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 {