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 { outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs() mkdirs()
resolve("AppVersionConfig.kt").writeText( 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.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage 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.tmdb.TmdbSettingsStorage
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage import com.nuvio.app.core.ui.PosterCardStyleStorage
@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
TraktAuthStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext)
TraktCommentsStorage.initialize(applicationContext) TraktCommentsStorage.initialize(applicationContext)
TraktLibraryStorage.initialize(applicationContext) TraktLibraryStorage.initialize(applicationContext)
TraktSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext)
ResumePromptStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext)
ContinueWatchingEnrichmentStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext)

View file

@ -16,12 +16,14 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_mdblist_settings", "nuvio_mdblist_settings",
"nuvio_trakt_auth", "nuvio_trakt_auth",
"nuvio_trakt_library", "nuvio_trakt_library",
"nuvio_trakt_settings",
"nuvio_watched", "nuvio_watched",
"nuvio_stream_link_cache", "nuvio_stream_link_cache",
"nuvio_continue_watching_preferences", "nuvio_continue_watching_preferences",
"nuvio_episode_release_notifications", "nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform", "nuvio_episode_release_notifications_platform",
"nuvio_watch_progress", "nuvio_watch_progress",
"nuvio_collections",
"nuvio_plugins", "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 preferencesName = "nuvio_theme_settings"
private const val selectedThemeKey = "selected_theme" private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled" private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language" 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 val globalSyncKeys = listOf(selectedAppLanguageKey)
private var preferences: SharedPreferences? = null private var preferences: SharedPreferences? = null
@ -51,6 +56,19 @@ actual object ThemeSettingsStorage {
?.apply() ?.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? { actual fun loadSelectedAppLanguage(): String? {
val value = preferences?.getString(selectedAppLanguageKey, null) val value = preferences?.getString(selectedAppLanguageKey, null)
if (value != null) return value if (value != null) return value
@ -75,6 +93,7 @@ actual object ThemeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
} }
@ -86,6 +105,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) 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">App Language</string>
<string name="settings_appearance_app_language_sheet_title">Choose 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_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_poster_customization_description">Tune card width and corner radius.</string>
<string name="settings_appearance_section_display">DISPLAY</string> <string name="settings_appearance_section_display">DISPLAY</string>
<string name="settings_appearance_section_home">HOME</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_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">Show Hero Section</string>
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</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">%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_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
<string name="settings_homescreen_visible">Visible</string> <string name="settings_homescreen_visible">Visible</string>
@ -506,6 +510,10 @@
<string name="settings_show_secret">Show value</string> <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_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_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_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</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> <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_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_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_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_home">HOME</string>
<string name="settings_content_discovery_section_sources">SOURCES</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> <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_episode_style_list_description">Detail-first stacked cards</string>
<string name="settings_meta_episodes">Episodes</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_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_group_label">Group %1$d</string>
<string name="settings_meta_more_like_this">More like this</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> <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_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_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="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_audience_score">Audience Score</string>
<string name="source_imdb">IMDb</string> <string name="source_imdb">IMDb</string>
<string name="source_letterboxd">Letterboxd</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="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_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_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">Choose an avatar</string>
<string name="profile_choose_avatar_below">Choose an avatar below.</string> <string name="profile_choose_avatar_below">Choose an avatar below.</string>
<string name="profile_create_profile">Create Profile</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_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_delete_title">Delete Profile</string>
<string name="profile_edit_add_title">Add 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.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -60,6 +61,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme 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.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository 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.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders 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.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab 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.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen 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.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository 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.trakt.TraktListTab
import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -262,6 +269,20 @@ enum class AppScreenTab {
Settings, 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 { private enum class AppGateScreen {
Loading, Loading,
Auth, Auth,
@ -295,13 +316,36 @@ fun App() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted() NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles() ProfileRepository.loadCachedProfiles()
AvatarRepository.fetchAvatars()
} }
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember { val networkStatusUiState by remember {
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.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 gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) } var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) } var isNewProfile by remember { mutableStateOf(false) }
@ -468,6 +512,11 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } 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 showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) } var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) } var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
@ -486,10 +535,6 @@ private fun MainAppContent(
LibraryRepository.ensureLoaded() LibraryRepository.ensureLoaded()
LibraryRepository.uiState LibraryRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val playerSettingsUiState by remember { val playerSettingsUiState by remember {
@ -508,7 +553,7 @@ private fun MainAppContent(
NetworkStatusRepository.uiState NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) 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 initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
@ -521,6 +566,42 @@ private fun MainAppContent(
.sorted() .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) { LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted() NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync() EpisodeReleaseNotificationsRepository.refreshAsync()
@ -892,6 +973,8 @@ private fun MainAppContent(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val isTabletLayout = maxWidth >= 768.dp val isTabletLayout = maxWidth >= 768.dp
val useNativeBottomTabs =
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
val onProfileSelected: (NuvioProfile) -> Unit = { profile -> val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
profileSwitchLoading = true profileSwitchLoading = true
selectedTab = AppScreenTab.Home selectedTab = AppScreenTab.Home
@ -906,7 +989,7 @@ private fun MainAppContent(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0), contentWindowInsets = WindowInsets(0),
bottomBar = { bottomBar = {
if (!isTabletLayout) { if (!isTabletLayout && !useNativeBottomTabs) {
NuvioNavigationBar { NuvioNavigationBar {
NavItem( NavItem(
selected = selectedTab == AppScreenTab.Home, selected = selectedTab == AppScreenTab.Home,
@ -942,6 +1025,9 @@ private fun MainAppContent(
}, },
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
) {
AppTabHost( AppTabHost(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -992,8 +1078,9 @@ private fun MainAppContent(
}, },
onInitialHomeContentRendered = { initialHomeReady = true }, onInitialHomeContentRendered = { initialHomeReady = true },
) )
}
if (isTabletLayout) { if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar( TabletFloatingTopBar(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },
@ -1664,12 +1751,12 @@ private fun MainAppContent(
onToggleLibrary = { onToggleLibrary = {
selectedPosterForActions?.let { preview -> selectedPosterForActions?.let { preview ->
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) { if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem) LibraryRepository.toggleSaved(libraryItem)
} else { } else {
pickerItem = libraryItem pickerItem = libraryItem
pickerTitle = preview.name pickerTitle = preview.name
pickerTabs = LibraryRepository.traktListTabs() pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false } pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true pickerPending = true
pickerError = null pickerError = null
@ -1677,7 +1764,7 @@ private fun MainAppContent(
coroutineScope.launch { coroutineScope.launch {
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs pickerTabs = tabs
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) 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.StreamLaunchStore
import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository 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.core.ui.PosterCardStyleRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
ThemeSettingsRepository.clearLocalState() ThemeSettingsRepository.clearLocalState()
PosterCardStyleRepository.clearLocalState() PosterCardStyleRepository.clearLocalState()
TraktAuthRepository.clearLocalState() TraktAuthRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
PlayerSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState()
CatalogRepository.clear() CatalogRepository.clear()
StreamsRepository.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.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktCommentsSettings 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.ContinueWatchingPreferencesStorage
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.postgrest
@ -150,12 +152,14 @@ object ProfileSettingsSync {
val signatureFlows = listOf( val signatureFlows = listOf(
ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.selectedTheme.map { "theme" },
ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" },
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" }, PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" }, PlayerSettingsRepository.uiState.map { "player" },
TmdbSettingsRepository.uiState.map { "tmdb" }, TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" }, MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" }, MetaScreenSettingsRepository.uiState.map { "meta" },
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
TraktSettingsRepository.uiState.map { "trakt_settings" },
TraktCommentsSettings.enabled.map { "trakt_comments" }, TraktCommentsSettings.enabled.map { "trakt_comments" },
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
) )
@ -199,6 +203,7 @@ object ProfileSettingsSync {
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
notificationsSettings = NotificationsSettingsPayload( notificationsSettings = NotificationsSettingsPayload(
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
@ -230,6 +235,9 @@ object ProfileSettingsSync {
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
ContinueWatchingPreferencesRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged()
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
TraktSettingsRepository.onProfileChanged()
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
TraktCommentsSettings.onProfileChanged() TraktCommentsSettings.onProfileChanged()
@ -244,6 +252,7 @@ object ProfileSettingsSync {
MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded()
ContinueWatchingPreferencesRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.ensureLoaded()
EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded()
} }
@ -257,12 +266,14 @@ object ProfileSettingsSync {
private fun currentObservedStateSignature(): String = listOf( private fun currentObservedStateSignature(): String = listOf(
"theme=${ThemeSettingsRepository.selectedTheme.value.name}", "theme=${ThemeSettingsRepository.selectedTheme.value.name}",
"amoled=${ThemeSettingsRepository.amoledEnabled.value}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}",
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}",
"continue=${ContinueWatchingPreferencesRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}",
"trakt_settings=${TraktSettingsRepository.uiState.value}",
"trakt_comments=${TraktCommentsSettings.enabled.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}",
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
).joinToString(separator = "||") ).joinToString(separator = "||")
@ -283,6 +294,7 @@ private data class MobileProfileSettingsFeatures(
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: 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("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(), @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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable @Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
@Composable @Composable
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
val navigationBarBottom = nuvioBottomNavigationBarInsets() val navigationBarBottom = nuvioBottomNavigationBarInsets()
.asPaddingValues() .asPaddingValues()
.calculateBottomPadding() .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.LibraryRepository
import com.nuvio.app.features.library.toMetaPreview 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -124,7 +127,7 @@ object CatalogRepository {
catalogId = request.catalogId, catalogId = request.catalogId,
genre = request.genre, genre = request.genre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) ).withUnreleasedFilter()
}.fold( }.fold(
onSuccess = { page -> onSuccess = { page ->
if (activeRequest != request) return@fold 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( private data class CatalogRequest(
val manifestUrl: String, val manifestUrl: String,
val type: 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.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.home.MetaPreview 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.PosterShape
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -74,20 +75,21 @@ fun CatalogScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val posterCardStyle = rememberPosterCardStyleUiState() val posterCardStyle = rememberPosterCardStyleUiState()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) } var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) { LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
CatalogRepository.load( CatalogRepository.load(
manifestUrl = manifestUrl, manifestUrl = manifestUrl,
type = type, type = type,
catalogId = catalogId, catalogId = catalogId,
genre = genre, genre = genre,
supportsPagination = supportsPagination, supportsPagination = supportsPagination,
force = false, force = true,
) )
} }

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
@ -56,17 +57,14 @@ object CollectionSyncService {
return return
} }
val remoteJson = blob.collectionsJson.toString() val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
JsonArray(emptyList())
} else {
blob.collectionsJson
}
val remoteJson = remoteCollectionsJson.toString()
val localJson = CollectionRepository.exportToJson() 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
}
}
if (remoteJson == localJson) { if (remoteJson == localJson) {
log.d { "pullFromServer — remote matches local, no update needed" } log.d { "pullFromServer — remote matches local, no update needed" }
return return
@ -78,7 +76,7 @@ object CollectionSyncService {
if (remoteCollections != null) { if (remoteCollections != null) {
isSyncingFromRemote = true isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson) CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
isSyncingFromRemote = false isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" } log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else { } else {

View file

@ -3,14 +3,18 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE 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.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.core.i18n.localizedMediaTypeLabel 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview 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.home.stableKey
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -320,7 +324,7 @@ object FolderDetailRepository {
genre = currentTab.genre, genre = currentTab.genre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) )
} }.withUnreleasedFilter()
}.onSuccess { page -> }.onSuccess { page ->
updateTab(index) { tab -> updateTab(index) { tab ->
val mergedItems = if (reset) { val mergedItems = if (reset) {
@ -418,6 +422,12 @@ object FolderDetailRepository {
private fun Boolean?.orFalse(): Boolean = this == true 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 = private fun tmdbCatalogId(source: CollectionSource): String =
buildString { buildString {
append("tmdb_") 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.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText 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.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -48,14 +51,14 @@ object MetaDetailsRepository {
cachedEntry.metaScreenMeta cachedEntry.metaScreenMeta
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint } ?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
?.let { cachedMeta -> ?.let { cachedMeta ->
_uiState.value = MetaDetailsUiState(meta = cachedMeta) _uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
val cachedBaseMeta = cachedEntry.baseMeta val cachedBaseMeta = cachedEntry.baseMeta
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta) _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
@ -81,7 +84,7 @@ object MetaDetailsRepository {
settingsFingerprint = metaScreenSettingsFingerprint, settingsFingerprint = metaScreenSettingsFingerprint,
) )
} }
_uiState.value = MetaDetailsUiState(meta = enrichedMeta) _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
} }
return return
@ -302,7 +305,7 @@ object MetaDetailsRepository {
cachedMetaByRequestKey[requestKey] = cachedEntry cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = meta) _uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
@ -324,7 +327,7 @@ object MetaDetailsRepository {
metaScreenMeta = enrichedMeta, metaScreenMeta = enrichedMeta,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
) )
_uiState.value = MetaDetailsUiState(meta = enrichedMeta) _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
} }
@ -374,6 +377,15 @@ object MetaDetailsRepository {
return "${settings.enabled}:${settings.apiKey.trim()}:$providers" 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> { fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList() 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.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy 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.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import com.nuvio.app.features.trakt.TraktCommentsRepository 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 pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) } 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 && val shouldShowComments = commentsEnabled &&
traktAuthUiState.mode == TraktConnectionMode.CONNECTED && traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
@ -192,6 +194,30 @@ fun MetaDetailsScreen(
isCommentsLoading = false 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) { LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) { if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
autoLoadAttempted = true autoLoadAttempted = true
@ -274,20 +300,16 @@ fun MetaDetailsScreen(
val isSaved = remember( val isSaved = remember(
libraryUiState.items, libraryUiState.items,
libraryUiState.sections, libraryUiState.sections,
traktAuthUiState.mode, libraryUiState.sourceMode,
meta.id, meta.id,
meta.type, meta.type,
) { ) {
LibraryRepository.isSaved(meta.id, meta.type) LibraryRepository.isSaved(meta.id, meta.type)
} }
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val openLibraryListPicker = remember(meta) {
val toggleSaved = remember(meta, isTraktConnected) {
{ {
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L) val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
if (!isTraktConnected) { pickerTabs = LibraryRepository.libraryListTabs()
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerTabs = LibraryRepository.traktListTabs()
pickerMembership = pickerTabs.associate { it.key to false } pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true pickerPending = true
pickerError = null pickerError = null
@ -295,7 +317,7 @@ fun MetaDetailsScreen(
detailsScope.launch { detailsScope.launch {
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs pickerTabs = tabs
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)
@ -308,6 +330,10 @@ fun MetaDetailsScreen(
Unit Unit
} }
} }
val toggleSaved = remember(meta) {
{
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
} }
val movieProgress = watchProgressUiState.byVideoId[meta.id] val movieProgress = watchProgressUiState.byVideoId[meta.id]
?.takeUnless { it.isCompleted } ?.takeUnless { it.isCompleted }
@ -637,6 +663,7 @@ fun MetaDetailsScreen(
onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved, onSaveClick = toggleSaved,
onSaveLongClick = openLibraryListPicker,
showManualPlayOption = showManualPlayOption, showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber, preferredEpisodeNumber = seriesAction?.episodeNumber,
@ -653,6 +680,7 @@ fun MetaDetailsScreen(
commentsCurrentPage = commentsCurrentPage, commentsCurrentPage = commentsCurrentPage,
commentsPageCount = commentsPageCount, commentsPageCount = commentsPageCount,
commentsError = commentsError, commentsError = commentsError,
episodeImdbRatings = episodeImdbRatings,
onRetryComments = { onRetryComments = {
detailsScope.launch { detailsScope.launch {
isCommentsLoading = true isCommentsLoading = true
@ -687,6 +715,7 @@ fun MetaDetailsScreen(
onTrailerClick = resolveTrailer, onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId, progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys, watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick, onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta, 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 @Composable
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
private fun ConfiguredMetaSections( private fun ConfiguredMetaSections(
@ -944,6 +997,7 @@ private fun ConfiguredMetaSections(
onPrimaryPlayClick: () -> Unit, onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?, onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onSaveLongClick: (() -> Unit)?,
showManualPlayOption: Boolean, showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?, preferredEpisodeSeasonNumber: Int?,
preferredEpisodeNumber: Int?, preferredEpisodeNumber: Int?,
@ -960,12 +1014,14 @@ private fun ConfiguredMetaSections(
commentsCurrentPage: Int, commentsCurrentPage: Int,
commentsPageCount: Int, commentsPageCount: Int,
commentsError: String?, commentsError: String?,
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit, onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit, onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit, onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit, onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>, progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>, watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit, onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?, onOpenMeta: ((MetaPreview) -> Unit)?,
@ -1008,6 +1064,7 @@ private fun ConfiguredMetaSections(
onPlayClick = onPrimaryPlayClick, onPlayClick = onPrimaryPlayClick,
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
onSaveClick = onSaveClick, onSaveClick = onSaveClick,
onSaveLongClick = onSaveLongClick,
) )
} }
MetaScreenSectionKey.OVERVIEW -> { MetaScreenSectionKey.OVERVIEW -> {
@ -1057,6 +1114,8 @@ private fun ConfiguredMetaSections(
episodeCardStyle = settings.episodeCardStyle, episodeCardStyle = settings.episodeCardStyle,
progressByVideoId = progressByVideoId, progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
episodeRatings = episodeImdbRatings,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick, onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress, onEpisodeLongPress = onEpisodeLongPress,
) )

View file

@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
val cinematicBackground: Boolean = false, val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false, val tabLayout: Boolean = false,
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
val blurUnwatchedEpisodes: Boolean = false,
) )
enum class MetaEpisodeCardStyle { enum class MetaEpisodeCardStyle {
@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
@SerialName("tvStyleLayout") @SerialName("tvStyleLayout")
val tabLayout: Boolean = false, val tabLayout: Boolean = false,
val episodeCardStyle: String = "horizontal", val episodeCardStyle: String = "horizontal",
@SerialName("blur_unwatched_episodes")
val blurUnwatchedEpisodes: Boolean = false,
) )
private data class MetaScreenSectionDefinition( private data class MetaScreenSectionDefinition(
@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() { fun ensureLoaded() {
@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
tabLayout = parsed.tabLayout tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle) episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
?: MetaEpisodeCardStyle.Horizontal ?: MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
preferences = parsed.items.mapNotNull { item -> preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item key to item
@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState() _uiState.value = MetaScreenSettingsUiState()
ensureLoaded() ensureLoaded()
} }
@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
persist() persist()
} }
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
ensureLoaded()
blurUnwatchedEpisodes = enabled
publish()
persist()
}
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) { fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded() ensureLoaded()
if (!key.canBeTabbed) return if (!key.canBeTabbed) return
@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
preferences.clear() preferences.clear()
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState() _uiState.value = MetaScreenSettingsUiState()
} }
@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
cinematicBackground: Boolean, cinematicBackground: Boolean,
tabLayout: Boolean, tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
blurUnwatchedEpisodes: Boolean = false,
) { ) {
ensureLoaded() ensureLoaded()
this.cinematicBackground = cinematicBackground this.cinematicBackground = cinematicBackground
this.tabLayout = tabLayout this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle this.episodeCardStyle = episodeCardStyle
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
preferences = items.associate { item -> preferences = items.associate { item ->
item.key to StoredMetaScreenSectionPreference( item.key to StoredMetaScreenSectionPreference(
key = item.key.name, key = item.key.name,
@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
normalizePreferences() normalizePreferences()
publish() publish()
persist() persist()
@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground, cinematicBackground = cinematicBackground,
tabLayout = tabLayout, tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle, episodeCardStyle = episodeCardStyle,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
) )
} }
@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground, cinematicBackground = cinematicBackground,
tabLayout = tabLayout, tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle), 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 package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem 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.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id), content = WatchingContentRef(type = type, id = id),
seasonNumber = season, seasonNumber = season,
episodeNumber = episode, episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
) )
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction = 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -44,6 +41,7 @@ fun DetailActionButtons(
onPlayClick: () -> Unit = {}, onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null, onPlayLongClick: (() -> Unit)? = null,
onSaveClick: () -> Unit = {}, onSaveClick: () -> Unit = {},
onSaveLongClick: (() -> Unit)? = null,
) { ) {
val playPainter = appIconPainter(AppIconResource.PlayerPlay) val playPainter = appIconPainter(AppIconResource.PlayerPlay)
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus) val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
@ -96,11 +94,24 @@ fun DetailActionButtons(
} }
} }
OutlinedButton( Surface(
onClick = onSaveClick,
modifier = rowButtonModifier.height(50.dp), modifier = rowButtonModifier.height(50.dp),
shape = RoundedCornerShape(40.dp), shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
contentColor = 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) { if (isSaved) {
Icon( Icon(
@ -127,4 +138,5 @@ fun DetailActionButtons(
) )
} }
} }
}
} }

View file

@ -15,12 +15,14 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -45,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -76,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
private val log = Logger.withTag("SeriesContent") private val log = Logger.withTag("SeriesContent")
@ -90,6 +96,8 @@ fun DetailSeriesContent(
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(), progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
watchedKeys: Set<String> = emptySet(), watchedKeys: Set<String> = emptySet(),
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
) { ) {
@ -276,6 +284,8 @@ fun DetailSeriesContent(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressByVideoId = progressByVideoId, progressByVideoId = progressByVideoId,
episodeRatings = episodeRatings,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
preferredEpisodeNumber = preferredEpisodeNumber, preferredEpisodeNumber = preferredEpisodeNumber,
onEpisodeClick = onEpisodeClick, onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress, onEpisodeLongPress = onEpisodeLongPress,
@ -295,13 +305,15 @@ fun DetailSeriesContent(
video = episode, video = episode,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId], progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched( WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
metaType = meta.type, metaType = meta.type,
metaId = meta.id, metaId = meta.id,
episode = episode, episode = episode,
), ),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
sizing = sizing, sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -553,6 +565,8 @@ private fun EpisodeHorizontalRow(
watchedKeys: Set<String>, watchedKeys: Set<String>,
fallbackImage: String?, fallbackImage: String?,
progressByVideoId: Map<String, WatchProgressEntry>, progressByVideoId: Map<String, WatchProgressEntry>,
episodeRatings: Map<Pair<Int, Int>, Double>,
blurUnwatchedEpisodes: Boolean,
preferredEpisodeNumber: Int? = null, preferredEpisodeNumber: Int? = null,
onEpisodeClick: ((MetaVideo) -> Unit)?, onEpisodeClick: ((MetaVideo) -> Unit)?,
onEpisodeLongPress: ((MetaVideo) -> Unit)?, onEpisodeLongPress: ((MetaVideo) -> Unit)?,
@ -597,13 +611,15 @@ private fun EpisodeHorizontalRow(
video = episode, video = episode,
fallbackImage = fallbackImage, fallbackImage = fallbackImage,
progressEntry = progressByVideoId[episodeVideoId], progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched( WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
metaType = metaType, metaType = metaType,
metaId = parentMetaId, metaId = parentMetaId,
episode = episode, episode = episode,
), ),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
metrics = rowMetrics, metrics = rowMetrics,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -618,12 +634,17 @@ private fun EpisodeHorizontalCard(
video: MetaVideo, video: MetaVideo,
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?, progressEntry: WatchProgressEntry?,
imdbRating: Double?,
isWatched: Boolean, isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
metrics: EpisodeHorizontalCardMetrics, metrics: EpisodeHorizontalCardMetrics,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
) { ) {
val cardShape = RoundedCornerShape(metrics.cornerRadius) 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( Box(
modifier = Modifier modifier = Modifier
.width(metrics.cardWidth) .width(metrics.cardWidth)
@ -642,11 +663,14 @@ private fun EpisodeHorizontalCard(
), ),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = video.title, contentDescription = video.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, 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( NuvioAnimatedWatchedBadge(
isVisible = isWatched, isVisible = isWatched,
modifier = Modifier modifier = Modifier
@ -709,6 +709,15 @@ private fun EpisodeHorizontalCard(
), ),
verticalArrangement = Arrangement.spacedBy(6.dp), 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(
text = video.title, text = video.title,
style = MaterialTheme.typography.titleMedium.copy( style = MaterialTheme.typography.titleMedium.copy(
@ -734,30 +743,42 @@ private fun EpisodeHorizontalCard(
) )
} }
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes -> runtimeLabel?.let { runtime ->
Text( Text(
text = formatEpisodeRuntime(runtimeMinutes), text = runtime,
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
color = Color.White.copy(alpha = 0.78f), color = Color.White.copy(alpha = 0.78f),
maxLines = 1, maxLines = 1,
) )
} }
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate -> 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(
text = formattedDate, text = date,
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
color = Color.White.copy(alpha = 0.78f), color = Color.White.copy(alpha = 0.78f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
) )
} }
} }
} }
}
progressEntry progressEntry
?.takeIf { it.durationMs > 0L && !it.isCompleted } ?.takeIf { it.durationMs > 0L && !it.isCompleted }
@ -793,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
val metaTextSize: androidx.compose.ui.unit.TextUnit, val metaTextSize: androidx.compose.ui.unit.TextUnit,
val badgeTextSize: androidx.compose.ui.unit.TextUnit, val badgeTextSize: androidx.compose.ui.unit.TextUnit,
val badgeRadius: Dp, val badgeRadius: Dp,
val badgeHorizontalPadding: Dp,
val badgeVerticalPadding: Dp,
val imdbLogoWidth: Dp,
val imdbLogoHeight: Dp,
) )
@Composable @Composable
@ -815,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 3, overviewMaxLines = 3,
metaTextSize = 12.sp, metaTextSize = 12.sp,
badgeTextSize = 11.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( maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
@ -834,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 3, overviewMaxLines = 3,
metaTextSize = 12.sp, metaTextSize = 12.sp,
badgeTextSize = 10.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( maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
@ -853,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
overviewMaxLines = 2, overviewMaxLines = 2,
metaTextSize = 11.sp, metaTextSize = 11.sp,
badgeTextSize = 10.sp, badgeTextSize = 10.sp,
badgeRadius = 5.dp, badgeRadius = 6.dp,
badgeHorizontalPadding = 8.dp,
badgeVerticalPadding = 4.dp,
imdbLogoWidth = 24.dp,
imdbLogoHeight = 12.dp,
) )
else -> EpisodeHorizontalCardMetrics( else -> EpisodeHorizontalCardMetrics(
@ -873,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
metaTextSize = 10.sp, metaTextSize = 10.sp,
badgeTextSize = 9.sp, badgeTextSize = 9.sp,
badgeRadius = 5.dp, 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) 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun EpisodeListCard( private fun EpisodeListCard(
video: MetaVideo, video: MetaVideo,
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?, progressEntry: WatchProgressEntry?,
imdbRating: Double?,
isWatched: Boolean, isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
sizing: SeriesContentSizing, sizing: SeriesContentSizing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
) { ) {
val cardShape = RoundedCornerShape(sizing.cardRadius) 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( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -923,11 +1028,14 @@ private fun EpisodeListCard(
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)), .clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = video.title, contentDescription = video.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} else { } 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 modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(start = 8.dp, top = 8.dp) .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,
)
}
NuvioAnimatedWatchedBadge( NuvioAnimatedWatchedBadge(
isVisible = isWatched, isVisible = isWatched,
@ -991,16 +1084,21 @@ private fun EpisodeListCard(
fontSize = sizing.titleTextSize, fontSize = sizing.titleTextSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = sizing.titleLineHeight, lineHeight = sizing.titleLineHeight,
letterSpacing = 0.3.sp, letterSpacing = 0.sp,
), ),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = sizing.titleMaxLines, maxLines = sizing.titleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate -> if (formattedDate != null || ratingLabel != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
formattedDate?.let { date ->
Text( Text(
text = formattedDate, text = date,
style = MaterialTheme.typography.labelMedium.copy( style = MaterialTheme.typography.labelMedium.copy(
fontSize = sizing.metaTextSize, fontSize = sizing.metaTextSize,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -1010,6 +1108,16 @@ private fun EpisodeListCard(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
ratingLabel?.let { rating ->
ImdbEpisodeRatingBadge(
rating = rating,
logoWidth = 24.dp,
logoHeight = 12.dp,
textSize = sizing.metaTextSize,
)
}
}
}
if (!video.overview.isNullOrBlank()) { if (!video.overview.isNullOrBlank()) {
Text( Text(
@ -1211,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String =
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty() localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
else -> runBlocking { getString(Res.string.details_episode_badge_file) } 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) { val completedEpisodes = remember(uiState.items) {
uiState.completedItems uiState.completedItems
.filter { it.isEpisode } .filter { it.isEpisode }
.sortedByDescending { it.updatedAtEpochMs } .sortedForSeriesDownloads()
} }
val selectedShowTitle = remember(selectedShowId, completedEpisodes) { val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
@ -229,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
) { ) {
val showEpisodes = episodes val showEpisodes = episodes
.filter { it.parentMetaId == showId } .filter { it.parentMetaId == showId }
.sortedForSeriesDownloads()
val seasons = showEpisodes val seasons = showEpisodes
.groupBy { it.seasonNumber ?: 0 } .groupBy { it.seasonNumber ?: 0 }
@ -268,10 +269,7 @@ private fun LazyListScope.downloadsShowContent(
) )
} }
val sortedEpisodes = entries.sortedWith( val sortedEpisodes = entries.sortedForSeriesDownloads()
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
.thenByDescending { it.updatedAtEpochMs },
)
items( items(
items = sortedEpisodes, items = sortedEpisodes,
@ -298,6 +296,12 @@ private fun DownloadRow(
onRetry: () -> Unit, onRetry: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
val displayTitle = item.displayTitle()
val displaySubtitle = downloadDisplaySubtitle(
item = item,
displayTitle = displayTitle,
)
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -322,7 +326,7 @@ private fun DownloadRow(
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
Text( Text(
text = item.title, text = displayTitle,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@ -330,7 +334,7 @@ private fun DownloadRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = item.displaySubtitle, text = displaySubtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, 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 @Composable
private fun SectionTitle(title: String) { private fun SectionTitle(title: String) {
Text( Text(

View file

@ -52,6 +52,7 @@ internal object HomeCatalogParser {
posterShape = meta.string("posterShape").toPosterShape(), posterShape = meta.string("posterShape").toPosterShape(),
description = meta.string("description"), description = meta.string("description"),
releaseInfo = meta.string("releaseInfo"), releaseInfo = meta.string("releaseInfo"),
rawReleaseDate = meta.string("released"),
imdbRating = meta.string("imdbRating"), imdbRating = meta.string("imdbRating"),
genres = meta.array("genres").mapNotNull { genre -> genres = meta.array("genres").mapNotNull { genre ->
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }

View file

@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState( data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true, val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<HomeCatalogSettingsItem> = emptyList(), val items: List<HomeCatalogSettingsItem> = emptyList(),
) { ) {
val signature: String val signature: String
get() = buildString { get() = buildString {
append(heroEnabled) append(heroEnabled)
append('|') append('|')
append(hideUnreleasedContent)
append('|')
append( append(
items.joinToString(separator = "|") { item -> items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
@ -55,6 +58,7 @@ internal data class HomeCatalogPreference(
internal data class HomeCatalogSettingsSnapshot( internal data class HomeCatalogSettingsSnapshot(
val heroEnabled: Boolean, val heroEnabled: Boolean,
val hideUnreleasedContent: Boolean,
val preferences: Map<String, HomeCatalogPreference>, val preferences: Map<String, HomeCatalogPreference>,
) )
@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference(
@Serializable @Serializable
private data class StoredHomeCatalogSettingsPayload( private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true, val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<StoredHomeCatalogPreference> = emptyList(), val items: List<StoredHomeCatalogPreference> = emptyList(),
) )
@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository {
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList() private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf() private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
private var heroEnabled = true private var heroEnabled = true
private var hideUnreleasedContent = false
fun onProfileChanged() { fun onProfileChanged() {
hasLoaded = false hasLoaded = false
preferences.clear() preferences.clear()
heroEnabled = true heroEnabled = true
hideUnreleasedContent = false
definitions = emptyList() definitions = emptyList()
collectionDefinitions = emptyList() collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState() _uiState.value = HomeCatalogSettingsUiState()
@ -105,6 +112,7 @@ object HomeCatalogSettingsRepository {
collectionDefinitions = emptyList() collectionDefinitions = emptyList()
preferences.clear() preferences.clear()
heroEnabled = true heroEnabled = true
hideUnreleasedContent = false
_uiState.value = HomeCatalogSettingsUiState() _uiState.value = HomeCatalogSettingsUiState()
} }
@ -135,6 +143,7 @@ object HomeCatalogSettingsRepository {
ensureLoaded() ensureLoaded()
return HomeCatalogSettingsSnapshot( return HomeCatalogSettingsSnapshot(
heroEnabled = heroEnabled, heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
preferences = preferences.mapValues { (_, value) -> preferences = preferences.mapValues { (_, value) ->
HomeCatalogPreference( HomeCatalogPreference(
customTitle = value.customTitle, customTitle = value.customTitle,
@ -154,6 +163,15 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings() HomeRepository.applyCurrentSettings()
} }
fun setHideUnreleasedContent(enabled: Boolean) {
ensureLoaded()
if (hideUnreleasedContent == enabled) return
hideUnreleasedContent = enabled
publish()
persist()
HomeRepository.applyCurrentSettings()
}
fun setHeroSourceEnabled(key: String, enabled: Boolean) { fun setHeroSourceEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference -> updatePreference(key) { preference ->
if (!enabled) { if (!enabled) {
@ -181,6 +199,7 @@ object HomeCatalogSettingsRepository {
fun resetToDefaults() { fun resetToDefaults() {
ensureLoaded() ensureLoaded()
heroEnabled = true heroEnabled = true
hideUnreleasedContent = false
preferences.clear() preferences.clear()
normalizePreferences() normalizePreferences()
publish() publish()
@ -226,7 +245,9 @@ object HomeCatalogSettingsRepository {
if (parsedPayload != null) { if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled heroEnabled = parsedPayload.heroEnabled
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
publish()
return return
} }
@ -235,6 +256,7 @@ object HomeCatalogSettingsRepository {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
preferences = legacyItems.associateBy { it.key }.toMutableMap() preferences = legacyItems.associateBy { it.key }.toMutableMap()
publish()
} }
private fun normalizePreferences() { private fun normalizePreferences() {
@ -322,6 +344,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState( _uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled, heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
items = items, items = items,
) )
} }
@ -331,6 +354,7 @@ object HomeCatalogSettingsRepository {
json.encodeToString( json.encodeToString(
StoredHomeCatalogSettingsPayload( StoredHomeCatalogSettingsPayload(
heroEnabled = heroEnabled, heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
items = preferences.values.sortedBy { it.order }, items = preferences.values.sortedBy { it.order },
), ),
), ),
@ -411,11 +435,16 @@ object HomeCatalogSettingsRepository {
) )
} }
} }
return SyncHomeCatalogPayload(items = items) return SyncHomeCatalogPayload(
hideUnreleasedContent = hideUnreleasedContent,
items = items,
)
} }
fun applyFromRemote(payload: SyncHomeCatalogPayload) { fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded() ensureLoaded()
hideUnreleasedContent = payload.hideUnreleasedContent
if (payload.items.isNotEmpty()) {
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item -> preferences = payload.items.associate { item ->
val key = if (item.isCollection) { val key = if (item.isCollection) {
@ -431,6 +460,7 @@ object HomeCatalogSettingsRepository {
order = item.order, order = item.order,
) )
}.toMutableMap() }.toMutableMap()
}
hasLoaded = true hasLoaded = true
publish() publish()
persist() persist()

View file

@ -41,6 +41,7 @@ data class SyncCatalogItem(
@Serializable @Serializable
data class SyncHomeCatalogPayload( data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
val items: List<SyncCatalogItem> = emptyList(), val items: List<SyncCatalogItem> = emptyList(),
) )
@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService {
} }
if (remotePayload.items.isEmpty()) { 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() val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
if (localPayload.items.isNotEmpty()) { if (localPayload.items.isNotEmpty()) {
pushToRemote(profileId) 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.addons.ManagedAddon
import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -145,13 +146,17 @@ object HomeRepository {
) { ) {
val snapshot = HomeCatalogSettingsRepository.snapshot() val snapshot = HomeCatalogSettingsRepository.snapshot()
val preferences = snapshot.preferences 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 val sections = currentDefinitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.mapNotNull { definition -> .mapNotNull { definition ->
val preference = preferences[definition.key] val preference = preferences[definition.key]
if (preference?.enabled == false) return@mapNotNull null 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 if (section.items.isEmpty()) return@mapNotNull null
val customTitle = preference?.customTitle.orEmpty() val customTitle = preference?.customTitle.orEmpty()
section.copy( section.copy(
@ -164,6 +169,7 @@ object HomeRepository {
currentDefinitions currentDefinitions
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
.mapNotNull { definition -> cachedSections[definition.key] } .mapNotNull { definition -> cachedSections[definition.key] }
.map { section -> section.withReleaseFilter() }
.flatMap { section -> section.items } .flatMap { section -> section.items }
.distinctBy { item -> "${item.type}:${item.id}" } .distinctBy { item -> "${item.type}:${item.id}" }
.shuffled(heroRandom) .shuffled(heroRandom)

View file

@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository 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.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard 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.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.nextReleasedEpisodeAfter 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.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.trakt.TraktAuthRepository 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.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem 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.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem 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.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry 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.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef 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.collection.CollectionRepository
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.home.components.HomeCollectionRowSection
@ -87,6 +95,10 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember { val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded() TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated TraktAuthRepository.isAuthenticated
@ -114,17 +126,31 @@ fun HomeScreen(
} }
} }
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) { val isTraktProgressActive = remember(
if (!isTraktAuthenticated) { isTraktAuthenticated,
watchProgressUiState.entries traktSettingsUiState.watchProgressSource,
} else { ) {
val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L) shouldUseTraktProgress(
watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } isAuthenticated = isTraktAuthenticated,
} source = traktSettingsUiState.watchProgressSource,
)
} }
val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) { val effectiveWatchProgressEntries = remember(
if (isTraktAuthenticated) emptyList() else watchedUiState.items 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) { 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( val visibleContinueWatchingEntries = remember(
effectiveWatchProgressEntries, effectiveWatchProgressEntries,
latestCompletedBySeries, latestCompletedBySeries,
@ -159,11 +188,28 @@ fun HomeScreen(
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) } var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } 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 -> 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) { if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
return@mapNotNull null return@mapNotNull null
} }
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
return@mapNotNull null
}
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
cached.contentId to (cached.sortTimestamp to item) cached.contentId to (cached.sortTimestamp to item)
}.toMap() }.toMap()
@ -242,7 +288,11 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections) HomeCatalogSettingsRepository.syncCollections(collections)
} }
LaunchedEffect(completedSeriesCandidates, metaProviderKey) { LaunchedEffect(
completedSeriesCandidates,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
) {
if (completedSeriesCandidates.isEmpty()) { if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap() nextUpItemsBySeries = emptyMap()
return@LaunchedEffect return@LaunchedEffect
@ -263,7 +313,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber, seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber, episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
showUnairedNextUp = isTraktAuthenticated, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
) ?: return@withPermit null ) ?: return@withPermit null
val item = completedEntry.toContinueWatchingSeed(meta) val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode) .toUpNextContinueWatchingItem(nextEpisode)
@ -291,6 +341,10 @@ fun HomeScreen(
episodeTitle = item.episodeTitle, episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail, episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription, pauseDescription = item.pauseDescription,
released = item.released,
hasAired = item.released?.let { released ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
} ?: true,
lastWatched = pair.first, lastWatched = pair.first,
sortTimestamp = pair.first, sortTimestamp = pair.first,
seedSeason = item.nextUpSeedSeasonNumber, seedSeason = item.nextUpSeedSeasonNumber,
@ -353,12 +407,19 @@ fun HomeScreen(
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value) val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value) val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
val nativeBottomNavigationOverlayHeight =
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
nuvioSafeBottomPadding()
} else {
0.dp
}
val mobileHeroBelowSectionHeightHint = remember( val mobileHeroBelowSectionHeightHint = remember(
maxWidth.value, maxWidth.value,
continueWatchingPreferences.isVisible, continueWatchingPreferences.isVisible,
continueWatchingPreferences.style, continueWatchingPreferences.style,
continueWatchingItems.isNotEmpty(), continueWatchingItems.isNotEmpty(),
continueWatchingLayout, continueWatchingLayout,
nativeBottomNavigationOverlayHeight,
) { ) {
heroMobileBelowSectionHeightHint( heroMobileBelowSectionHeightHint(
maxWidthDp = maxWidth.value, maxWidthDp = maxWidth.value,
@ -366,6 +427,7 @@ fun HomeScreen(
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
continueWatchingStyle = continueWatchingPreferences.style, continueWatchingStyle = continueWatchingPreferences.style,
continueWatchingLayout = continueWatchingLayout, continueWatchingLayout = continueWatchingLayout,
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
) )
} }
@ -409,6 +471,8 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
layout = continueWatchingLayout, layout = continueWatchingLayout,
@ -432,6 +496,8 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
layout = continueWatchingLayout, layout = continueWatchingLayout,
@ -474,6 +540,8 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
layout = continueWatchingLayout, layout = continueWatchingLayout,
@ -525,7 +593,21 @@ fun HomeScreen(
} }
private const val HOME_CATALOG_PREVIEW_LIMIT = 18 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( private fun heroMobileBelowSectionHeightHint(
maxWidthDp: Float, maxWidthDp: Float,
@ -533,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
hasContinueWatchingItems: Boolean, hasContinueWatchingItems: Boolean,
continueWatchingStyle: ContinueWatchingSectionStyle, continueWatchingStyle: ContinueWatchingSectionStyle,
continueWatchingLayout: ContinueWatchingLayout, continueWatchingLayout: ContinueWatchingLayout,
bottomNavigationOverlayHeight: Dp,
): Dp? { ): Dp? {
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
return when (continueWatchingStyle) { val sectionHeight = when (continueWatchingStyle) {
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
ContinueWatchingSectionStyle.Poster -> ContinueWatchingSectionStyle.Poster ->
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
} }
return sectionHeight + bottomNavigationOverlayHeight
} }
internal fun buildHomeContinueWatchingItems( internal fun buildHomeContinueWatchingItems(
@ -548,6 +632,13 @@ internal fun buildHomeContinueWatchingItems(
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(), cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>, nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
): List<ContinueWatchingItem> { ): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
return buildList { return buildList {
addAll( addAll(
visibleEntries.map { entry -> visibleEntries.map { entry ->
@ -560,7 +651,8 @@ internal fun buildHomeContinueWatchingItems(
}, },
) )
addAll( addAll(
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) -> nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
HomeContinueWatchingCandidate( HomeContinueWatchingCandidate(
lastUpdatedEpochMs = lastUpdatedEpochMs, lastUpdatedEpochMs = lastUpdatedEpochMs,
item = item, item = item,
@ -574,7 +666,7 @@ internal fun buildHomeContinueWatchingItems(
.thenByDescending { it.isProgressEntry }, .thenByDescending { it.isProgressEntry },
) )
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { it.item.videoId } .distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
.map(HomeContinueWatchingCandidate::item) .map(HomeContinueWatchingCandidate::item)
} }
@ -632,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail, episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
released = released,
isNextUp = true, isNextUp = true,
nextUpSeedSeasonNumber = seedSeason, nextUpSeedSeasonNumber = seedSeason,
nextUpSeedEpisodeNumber = seedEpisode, nextUpSeedEpisodeNumber = seedEpisode,
@ -698,5 +791,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
episodeTitle = episodeTitle ?: fallback.episodeTitle, episodeTitle = episodeTitle ?: fallback.episodeTitle,
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
pauseDescription = pauseDescription ?: fallback.pauseDescription, 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
private fun continueWatchingProgressPercent(progressFraction: Float): Int = private fun continueWatchingProgressPercent(progressFraction: Float): Int =
(progressFraction * 100f).roundToInt().coerceIn(1, 99) (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 @Composable
internal fun HomeContinueWatchingSection( internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean = true,
blurNextUp: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sectionPadding: Dp? = null, sectionPadding: Dp? = null,
layout: ContinueWatchingLayout? = null, layout: ContinueWatchingLayout? = null,
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent( HomeContinueWatchingSectionContent(
items = items, items = items,
style = style, style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding, sectionPadding = sectionPadding,
layout = layout, layout = layout,
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent( HomeContinueWatchingSectionContent(
items = items, items = items,
style = style, style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
layout = rememberContinueWatchingLayout(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value),
@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection(
private fun HomeContinueWatchingSectionContent( private fun HomeContinueWatchingSectionContent(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
modifier: Modifier, modifier: Modifier,
sectionPadding: Dp, sectionPadding: Dp,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item, item = item,
layout = layout, layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } }, onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } },
) )
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item, item = item,
layout = layout, layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } }, onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } },
) )
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
private fun ContinueWatchingWideCard( private fun ContinueWatchingWideCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
onLongClick = onLongClick, onLongClick = onLongClick,
), ),
) { ) {
val artworkUrl = item.poster ?: item.background ?: item.imageUrl val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
ArtworkPanel( ArtworkPanel(
imageUrl = artworkUrl, imageUrl = artworkUrl,
width = layout.widePosterStripWidth, width = layout.widePosterStripWidth,
blurred = shouldBlurArtwork,
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
) )
Column( Column(
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
private fun ContinueWatchingPosterCard( private fun ContinueWatchingPosterCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
) )
.posterCardClickable(onClick = onClick, onLongClick = onLongClick), .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) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = item.title, contentDescription = item.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
@ -489,6 +543,7 @@ private fun ContinueWatchingPosterCard(
private fun ArtworkPanel( private fun ArtworkPanel(
imageUrl: String?, imageUrl: String?,
width: Dp, width: Dp,
blurred: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(
@ -500,7 +555,9 @@ private fun ArtworkPanel(
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, 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.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktLibraryRepository 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.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.postgrest
import io.github.jan.supabase.postgrest.rpc import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -65,12 +72,28 @@ object LibraryRepository {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) { if (authenticated) {
TraktLibraryRepository.preloadListTabsAsync() TraktLibraryRepository.preloadListTabsAsync()
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
runCatching { TraktLibraryRepository.refreshNow() } runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
} }
}
publish() 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 { syncScope.launch {
TraktLibraryRepository.uiState.collectLatest { TraktLibraryRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
@ -82,25 +105,31 @@ object LibraryRepository {
fun ensureLoaded() { fun ensureLoaded() {
TraktAuthRepository.ensureLoaded() TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktLibraryRepository.ensureLoaded() TraktLibraryRepository.ensureLoaded()
if (hasLoaded) return if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId) loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync() TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync() refreshTraktLibraryAsync()
} }
} }
}
fun onProfileChanged(profileId: Int) { fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return if (profileId == currentProfileId && hasLoaded) return
TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId) loadFromDisk(profileId)
TraktAuthRepository.onProfileChanged() TraktAuthRepository.onProfileChanged()
TraktLibraryRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync() TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync() refreshTraktLibraryAsync()
} }
} }
}
fun clearLocalState() { fun clearLocalState() {
hasLoaded = false hasLoaded = false
@ -130,7 +159,7 @@ object LibraryRepository {
suspend fun pullFromServer(profileId: Int) { suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId currentProfileId = profileId
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
runCatching { TraktLibraryRepository.refreshNow() } runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
publish() publish()
@ -157,7 +186,7 @@ object LibraryRepository {
fun toggleSaved(item: LibraryItem) { fun toggleSaved(item: LibraryItem) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
syncScope.launch { syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) } runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } } .onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
@ -175,7 +204,6 @@ object LibraryRepository {
fun save(item: LibraryItem) { fun save(item: LibraryItem) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) return
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
publish() publish()
persist() persist()
@ -184,7 +212,6 @@ object LibraryRepository {
fun remove(id: String) { fun remove(id: String) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) return
if (itemsById.remove(id) != null) { if (itemsById.remove(id) != null) {
publish() publish()
persist() persist()
@ -195,7 +222,7 @@ object LibraryRepository {
fun isSaved(id: String, type: String? = null): Boolean { fun isSaved(id: String, type: String? = null): Boolean {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
if (type != null) { if (type != null) {
return TraktLibraryRepository.isInAnyList(id, type) return TraktLibraryRepository.isInAnyList(id, type)
} }
@ -212,46 +239,65 @@ object LibraryRepository {
fun savedItem(id: String): LibraryItem? { fun savedItem(id: String): LibraryItem? {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
} }
return itemsById[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> { suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) {
return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
}
val inLocal = itemsById.containsKey(item.id) 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>) { suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
TraktLibraryRepository.applyMembershipChanges( val currentlyInLocal = itemsById.containsKey(item.id)
item = item, if (localDesired != currentlyInLocal) {
changes = TraktMembershipChanges(desiredMembership = desiredMembership), if (localDesired) {
)
publish()
return
}
val shouldBeSaved = desiredMembership.values.any { it }
if (shouldBeSaved) {
save(item) save(item)
} else { } else {
remove(item.id) remove(item.id)
} }
} }
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 {
publish()
}
}
private fun pushToServer() { private fun pushToServer() {
syncScope.launch { syncScope.launch {
if (TraktAuthRepository.isAuthenticated.value) return@launch
runCatching { runCatching {
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
val syncItems = itemsById.values.map { it.toSyncItem() } val syncItems = itemsById.values.map { it.toSyncItem() }
@ -267,7 +313,7 @@ object LibraryRepository {
} }
private fun publish() { private fun publish() {
if (TraktAuthRepository.isAuthenticated.value) { if (isTraktLibrarySourceActive()) {
val traktState = TraktLibraryRepository.uiState.value val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab -> val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty() val listItems = traktState.entriesByList[tab.key].orEmpty()
@ -334,9 +380,42 @@ object LibraryRepository {
publish() 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( private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
id = contentId, id = contentId,

View file

@ -50,6 +50,12 @@ fun LibraryScreen(
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
val retryLibraryLoad: () -> Unit = {
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
}
LaunchedEffect(networkStatusUiState.condition, isTraktSource) { LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
when (networkStatusUiState.condition) { when (networkStatusUiState.condition) {
@ -110,14 +116,7 @@ fun LibraryScreen(
NuvioNetworkOfflineCard( NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition, condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
onRetry = { onRetry = retryLibraryLoad,
NetworkStatusRepository.requestRefresh(force = true)
if (isTraktSource) {
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
}
},
) )
} else { } else {
HomeEmptyStateCard( HomeEmptyStateCard(
@ -128,6 +127,8 @@ fun LibraryScreen(
stringResource(Res.string.library_load_failed) stringResource(Res.string.library_load_failed)
}, },
message = uiState.errorMessage.orEmpty(), message = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(Res.string.action_retry),
onActionClick = retryLibraryLoad,
) )
} }
} }
@ -139,12 +140,7 @@ fun LibraryScreen(
NuvioNetworkOfflineCard( NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition, condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
onRetry = { onRetry = retryLibraryLoad,
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
},
) )
} else { } else {
HomeEmptyStateCard( HomeEmptyStateCard(

View file

@ -131,6 +131,7 @@ internal fun PlayerControlsShell(
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
metrics = metrics, metrics = metrics,
isLocked = isLocked, isLocked = isLocked,
onSubmitIntroClick = onSubmitIntroClick,
onLockToggle = onLockToggle, onLockToggle = onLockToggle,
onBack = onBack, onBack = onBack,
modifier = Modifier modifier = Modifier
@ -168,7 +169,6 @@ internal fun PlayerControlsShell(
onAudioClick = onAudioClick, onAudioClick = onAudioClick,
onSourcesClick = onSourcesClick, onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick, onEpisodesClick = onEpisodesClick,
onSubmitIntroClick = onSubmitIntroClick,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
@ -189,6 +189,7 @@ private fun PlayerHeader(
episodeTitle: String?, episodeTitle: String?,
metrics: PlayerLayoutMetrics, metrics: PlayerLayoutMetrics,
isLocked: Boolean, isLocked: Boolean,
onSubmitIntroClick: (() -> Unit)?,
onLockToggle: () -> Unit, onLockToggle: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -264,6 +265,15 @@ private fun PlayerHeader(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, 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( PlayerHeaderIconButton(
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
contentDescription = if (isLocked) { contentDescription = if (isLocked) {
@ -424,7 +434,6 @@ private fun ProgressControls(
onAudioClick: () -> Unit, onAudioClick: () -> Unit,
onSourcesClick: (() -> Unit)? = null, onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null,
onSubmitIntroClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
@ -506,13 +515,6 @@ private fun ProgressControls(
onClick = onEpisodesClick, onClick = onEpisodesClick,
) )
} }
if (onSubmitIntroClick != null) {
PlayerActionPillButton(
label = "Submit Intro",
icon = Icons.Rounded.Flag,
onClick = onSubmitIntroClick,
)
}
} }
} }
} }
@ -676,6 +678,9 @@ private fun PlayerActionPillButton(
text = label, text = label,
style = MaterialTheme.nuvioTypeScale.labelSm, style = MaterialTheme.nuvioTypeScale.labelSm,
color = Color.White, 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.details.MetaVideo
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState 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 nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
fun PlayerEpisodesPanel( fun PlayerEpisodesPanel(
visible: Boolean, visible: Boolean,
episodes: List<MetaVideo>, episodes: List<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
currentSeason: Int?, currentSeason: Int?,
currentEpisode: Int?, currentEpisode: Int?,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
// episode stream sub-view state // episode stream sub-view state
episodeStreamsState: EpisodeStreamsPanelState, episodeStreamsState: EpisodeStreamsPanelState,
onSeasonSelected: (Int) -> Unit, onSeasonSelected: (Int) -> Unit,
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
} else { } else {
EpisodesListSubView( EpisodesListSubView(
episodes = episodes, episodes = episodes,
parentMetaType = parentMetaType,
parentMetaId = parentMetaId,
currentSeason = currentSeason, currentSeason = currentSeason,
currentEpisode = currentEpisode, currentEpisode = currentEpisode,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onSeasonSelected = onSeasonSelected, onSeasonSelected = onSeasonSelected,
onEpisodeSelected = onEpisodeSelected, onEpisodeSelected = onEpisodeSelected,
onDismiss = onDismiss, onDismiss = onDismiss,
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
@Composable @Composable
private fun EpisodesListSubView( private fun EpisodesListSubView(
episodes: List<MetaVideo>, episodes: List<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
currentSeason: Int?, currentSeason: Int?,
currentEpisode: Int?, currentEpisode: Int?,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onSeasonSelected: (Int) -> Unit, onSeasonSelected: (Int) -> Unit,
onEpisodeSelected: (MetaVideo) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
) { _, episode -> ) { _, episode ->
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode 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( EpisodeRow(
episode = episode, episode = episode,
isCurrent = isCurrent, isCurrent = isCurrent,
isWatched = isWatched,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onClick = { onEpisodeSelected(episode) }, onClick = { onEpisodeSelected(episode) },
) )
} }
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
private fun EpisodeRow( private fun EpisodeRow(
episode: MetaVideo, episode: MetaVideo,
isCurrent: Boolean, isCurrent: Boolean,
isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
Row( Row(
modifier = Modifier modifier = Modifier
@ -342,7 +379,8 @@ private fun EpisodeRow(
modifier = Modifier modifier = Modifier
.width(80.dp) .width(80.dp)
.height(48.dp) .height(48.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp))
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository 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.details.MetaVideo
import com.nuvio.app.features.downloads.DownloadItem import com.nuvio.app.features.downloads.DownloadItem
import com.nuvio.app.features.downloads.DownloadsRepository 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.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.trakt.TraktScrobbleRepository 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.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -143,6 +145,18 @@ fun PlayerScreen(
PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle() }.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( BoxWithConstraints(
modifier = modifier modifier = modifier
@ -1799,8 +1813,13 @@ fun PlayerScreen(
PlayerEpisodesPanel( PlayerEpisodesPanel(
visible = showEpisodesPanel, visible = showEpisodesPanel,
episodes = allEpisodes, episodes = allEpisodes,
parentMetaType = parentMetaType,
parentMetaId = parentMetaId,
currentSeason = activeSeasonNumber, currentSeason = activeSeasonNumber,
currentEpisode = activeEpisodeNumber, currentEpisode = activeEpisodeNumber,
progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
episodeStreamsState = episodeStreamsPanelState.copy( episodeStreamsState = episodeStreamsPanelState.copy(
streamsUiState = episodeStreamsRepoState, streamsUiState = episodeStreamsRepoState,
), ),

View file

@ -78,6 +78,7 @@ fun ProfileEditScreen(
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") } var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) } var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) }
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) } var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
var isSaving by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
@ -90,17 +91,20 @@ fun ProfileEditScreen(
AvatarRepository.fetchAvatars() AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars() AvatarRepository.refreshAvatars()
} }
LaunchedEffect(isNew, avatars, selectedAvatarId) { LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) {
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) { if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) {
selectedAvatarId = avatars.first().id selectedAvatarId = avatars.first().id
} }
} }
val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) }
val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null
val selectedAvatarItem = remember(selectedAvatarId, avatars) { val selectedAvatarItem = remember(selectedAvatarId, avatars) {
selectedAvatarId?.let { id -> avatars.find { it.id == id } } selectedAvatarId?.let { id -> avatars.find { it.id == id } }
} }
val previewAccent = remember(selectedAvatarItem, fallbackColorHex) { val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null
parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex) val previewAccent = remember(visibleAvatarItem, fallbackColorHex) {
parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex)
} }
NuvioScreen(modifier = modifier) { NuvioScreen(modifier = modifier) {
@ -123,12 +127,47 @@ fun ProfileEditScreen(
usesPrimaryAddons = usesPrimaryAddons, usesPrimaryAddons = usesPrimaryAddons,
onNameChange = { name = it }, onNameChange = { name = it },
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it }, onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
selectedAvatar = selectedAvatarItem, selectedAvatar = visibleAvatarItem,
customAvatarUrl = customAvatarUrl,
accentColor = previewAccent, accentColor = previewAccent,
hasAvatarChoices = avatars.isNotEmpty(), 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 { item {
NuvioSurfaceCard { NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
@ -165,8 +204,11 @@ fun ProfileEditScreen(
AvatarChoiceItem( AvatarChoiceItem(
avatar = avatar, avatar = avatar,
size = avatarSize, size = avatarSize,
isSelected = avatar.id == selectedAvatarId, isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId,
onClick = { selectedAvatarId = avatar.id }, onClick = {
avatarUrl = ""
selectedAvatarId = avatar.id
},
) )
} }
} }
@ -220,16 +262,17 @@ fun ProfileEditScreen(
} else { } else {
stringResource(Res.string.collections_editor_save_changes) stringResource(Res.string.collections_editor_save_changes)
}, },
enabled = name.isNotBlank() && !isSaving, enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving,
onClick = { onClick = {
isSaving = true isSaving = true
scope.launch { scope.launch {
val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex
if (isNew) { if (isNew) {
ProfileRepository.createProfile( ProfileRepository.createProfile(
name = name, name = name,
avatarColorHex = avatarColorHex, avatarColorHex = avatarColorHex,
avatarId = selectedAvatarId, avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
avatarUrl = customAvatarUrl,
usesPrimaryAddons = usesPrimaryAddons, usesPrimaryAddons = usesPrimaryAddons,
) )
} else { } else {
@ -237,7 +280,8 @@ fun ProfileEditScreen(
profileIndex = currentProfile!!.profileIndex, profileIndex = currentProfile!!.profileIndex,
name = name, name = name,
avatarColorHex = avatarColorHex, avatarColorHex = avatarColorHex,
avatarId = selectedAvatarId, avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
avatarUrl = customAvatarUrl,
usesPrimaryAddons = usesPrimaryAddons, usesPrimaryAddons = usesPrimaryAddons,
) )
} }
@ -330,6 +374,7 @@ private fun ProfileIdentityCard(
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
onUsesPrimaryAddonsChange: (Boolean) -> Unit, onUsesPrimaryAddonsChange: (Boolean) -> Unit,
selectedAvatar: AvatarCatalogItem?, selectedAvatar: AvatarCatalogItem?,
customAvatarUrl: String?,
accentColor: Color, accentColor: Color,
hasAvatarChoices: Boolean, hasAvatarChoices: Boolean,
) { ) {
@ -345,16 +390,31 @@ private fun ProfileIdentityCard(
.size(88.dp) .size(88.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f), if (selectedAvatar != null || customAvatarUrl != null) {
accentColor
} else {
accentColor.copy(alpha = 0.18f)
},
) )
.border( .border(
width = 2.dp, 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, shape = CircleShape,
), ),
contentAlignment = Alignment.Center, 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( AsyncImage(
model = avatarStorageUrl(selectedAvatar.storagePath), model = avatarStorageUrl(selectedAvatar.storagePath),
contentDescription = selectedAvatar.displayName, contentDescription = selectedAvatar.displayName,
@ -410,6 +470,7 @@ private fun ProfileIdentityCard(
) )
Text( Text(
text = when { text = when {
customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected)
selectedAvatar != null -> stringResource( selectedAvatar != null -> stringResource(
Res.string.profile_avatar_selected, Res.string.profile_avatar_selected,
selectedAvatar.displayName, selectedAvatar.displayName,

View file

@ -12,6 +12,7 @@ data class NuvioProfile(
val name: String = "", val name: String = "",
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5", @SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
@SerialName("avatar_id") val avatarId: String? = null, @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_addons") val usesPrimaryAddons: Boolean = false,
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
@SerialName("pin_enabled") val pinEnabled: 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_addons") val usesPrimaryAddons: Boolean = false,
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
@SerialName("avatar_id") val avatarId: String? = null, @SerialName("avatar_id") val avatarId: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
) )
@Serializable @Serializable
@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf(
fun avatarStorageUrl(storagePath: String): String = fun avatarStorageUrl(storagePath: String): String =
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath" "${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.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository 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.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -135,6 +136,7 @@ object ProfileRepository {
) )
persist() persist()
WatchedRepository.onProfileChanged(profileIndex) WatchedRepository.onProfileChanged(profileIndex)
TraktSettingsRepository.onProfileChanged()
LibraryRepository.onProfileChanged(profileIndex) LibraryRepository.onProfileChanged(profileIndex)
WatchProgressRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex)
@ -177,6 +179,7 @@ object ProfileRepository {
name: String, name: String,
avatarColorHex: String, avatarColorHex: String,
avatarId: String? = null, avatarId: String? = null,
avatarUrl: String? = null,
usesPrimaryAddons: Boolean = false, usesPrimaryAddons: Boolean = false,
) { ) {
val existing = _state.value.profiles val existing = _state.value.profiles
@ -190,6 +193,7 @@ object ProfileRepository {
usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryAddons = profile.usesPrimaryAddons,
usesPrimaryPlugins = profile.usesPrimaryPlugins, usesPrimaryPlugins = profile.usesPrimaryPlugins,
avatarId = profile.avatarId, avatarId = profile.avatarId,
avatarUrl = profile.avatarUrl,
) )
} + ProfilePushPayload( } + ProfilePushPayload(
profileIndex = nextIndex, profileIndex = nextIndex,
@ -197,6 +201,7 @@ object ProfileRepository {
avatarColorHex = avatarColorHex, avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons, usesPrimaryAddons = usesPrimaryAddons,
avatarId = avatarId, avatarId = avatarId,
avatarUrl = avatarUrl,
) )
pushProfiles(allPayloads) pushProfiles(allPayloads)
@ -207,6 +212,7 @@ object ProfileRepository {
name: String, name: String,
avatarColorHex: String, avatarColorHex: String,
avatarId: String? = null, avatarId: String? = null,
avatarUrl: String? = null,
usesPrimaryAddons: Boolean = false, usesPrimaryAddons: Boolean = false,
) { ) {
val allPayloads = _state.value.profiles.map { profile -> val allPayloads = _state.value.profiles.map { profile ->
@ -216,7 +222,8 @@ object ProfileRepository {
name = name, name = name,
avatarColorHex = avatarColorHex, avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons, usesPrimaryAddons = usesPrimaryAddons,
avatarId = avatarId ?: profile.avatarId, avatarId = avatarId,
avatarUrl = avatarUrl,
) )
} else { } else {
ProfilePushPayload( ProfilePushPayload(
@ -226,6 +233,7 @@ object ProfileRepository {
usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryAddons = profile.usesPrimaryAddons,
usesPrimaryPlugins = profile.usesPrimaryPlugins, usesPrimaryPlugins = profile.usesPrimaryPlugins,
avatarId = profile.avatarId, avatarId = profile.avatarId,
avatarUrl = profile.avatarUrl,
) )
} }
} }
@ -355,6 +363,7 @@ object ProfileRepository {
name = p.name, name = p.name,
avatarColorHex = p.avatarColorHex, avatarColorHex = p.avatarColorHex,
avatarId = p.avatarId, avatarId = p.avatarId,
avatarUrl = p.avatarUrl,
usesPrimaryAddons = p.usesPrimaryAddons, usesPrimaryAddons = p.usesPrimaryAddons,
usesPrimaryPlugins = p.usesPrimaryPlugins, usesPrimaryPlugins = p.usesPrimaryPlugins,
) )

View file

@ -304,6 +304,9 @@ private fun ProfileAvatarCard(
val avatarItem = remember(profile.avatarId, avatars) { val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } } profile.avatarId?.let { id -> avatars.find { it.id == id } }
} }
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
profileAvatarImageUrl(profile, avatarItem)
}
val animAlpha = remember { Animatable(0f) } val animAlpha = remember { Animatable(0f) }
val animScale = remember { Animatable(0.85f) } val animScale = remember { Animatable(0.85f) }
@ -342,8 +345,8 @@ private fun ProfileAvatarCard(
modifier = Modifier.size(110.dp), modifier = Modifier.size(110.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (avatarItem != null) { if (avatarImageUrl != null) {
val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
Box( Box(
modifier = Modifier modifier = Modifier
.size(110.dp) .size(110.dp)
@ -364,15 +367,15 @@ private fun ProfileAvatarCard(
}, },
) )
.then( .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, else Modifier,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (avatarItem != null) { if (avatarImageUrl != null) {
AsyncImage( AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath), model = avatarImageUrl,
contentDescription = avatarItem.displayName, contentDescription = avatarItem?.displayName ?: profile.name,
modifier = Modifier.size(100.dp).clip(CircleShape), modifier = Modifier.size(100.dp).clip(CircleShape),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )

View file

@ -341,6 +341,9 @@ private fun PopupProfileBubble(
val avatarItem = remember(profile.avatarId, avatars) { val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } } profile.avatarId?.let { id -> avatars.find { it.id == id } }
} }
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
profileAvatarImageUrl(profile, avatarItem)
}
// Per-item entrance animation // Per-item entrance animation
val itemAlpha = remember { Animatable(0f) } val itemAlpha = remember { Animatable(0f) }
@ -393,8 +396,8 @@ private fun PopupProfileBubble(
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (avatarItem != null) { if (avatarImageUrl != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else { } else {
avatarColor.copy(alpha = 0.15f) avatarColor.copy(alpha = 0.15f)
}, },
@ -411,7 +414,7 @@ private fun PopupProfileBubble(
avatarColor.copy(alpha = 0.6f), avatarColor.copy(alpha = 0.6f),
CircleShape, CircleShape,
) )
avatarItem == null -> Modifier.border( avatarImageUrl == null -> Modifier.border(
1.5.dp, 1.5.dp,
avatarColor.copy(alpha = 0.3f), avatarColor.copy(alpha = 0.3f),
CircleShape, CircleShape,
@ -421,9 +424,9 @@ private fun PopupProfileBubble(
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (avatarItem != null) { if (avatarImageUrl != null) {
AsyncImage( AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath), model = avatarImageUrl,
contentDescription = profile.name, contentDescription = profile.name,
modifier = Modifier.size(48.dp).clip(CircleShape), modifier = Modifier.size(48.dp).clip(CircleShape),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar(
val avatarItem = remember(profile.avatarId, avatars) { val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } } profile.avatarId?.let { id -> avatars.find { it.id == id } }
} }
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
profileAvatarImageUrl(profile, avatarItem)
}
val borderColor = if (selected) { val borderColor = if (selected) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar(
.size(size.dp) .size(size.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (avatarItem != null) { if (avatarImageUrl != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else { } else {
avatarColor.copy(alpha = 0.15f) avatarColor.copy(alpha = 0.15f)
}, },
@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar(
.border(1.5.dp, borderColor, CircleShape), .border(1.5.dp, borderColor, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (avatarItem != null) { if (avatarImageUrl != null) {
AsyncImage( AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath), model = avatarImageUrl,
contentDescription = profile.name, contentDescription = profile.name,
modifier = Modifier.size(size.dp).clip(CircleShape), modifier = Modifier.size(size.dp).clip(CircleShape),
contentScale = ContentScale.Crop, 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.AddonCatalog
import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.AddonExtraProperty
import com.nuvio.app.features.addons.ManagedAddon 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.buildCatalogUrl
import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -37,6 +41,7 @@ object SearchRepository {
private var activeDiscoverJob: Job? = null private var activeDiscoverJob: Job? = null
private var lastRequestKey: String? = null private var lastRequestKey: String? = null
private var discoverSources: List<DiscoverCatalogOption> = emptyList() private var discoverSources: List<DiscoverCatalogOption> = emptyList()
private var lastDiscoverHideUnreleasedContent: Boolean? = null
fun search(query: String, addons: List<ManagedAddon>) { fun search(query: String, addons: List<ManagedAddon>) {
val normalizedQuery = query.trim() val normalizedQuery = query.trim()
@ -71,6 +76,8 @@ object SearchRepository {
val requestKey = buildString { val requestKey = buildString {
append(normalizedQuery.lowercase()) append(normalizedQuery.lowercase())
append('|') append('|')
append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent)
append('|')
append( append(
requests.joinToString(separator = "|") { request -> requests.joinToString(separator = "|") { request ->
"${request.addon.manifestUrl}:${request.type}:${request.catalogId}" "${request.addon.manifestUrl}:${request.type}:${request.catalogId}"
@ -119,6 +126,7 @@ object SearchRepository {
activeDiscoverJob?.cancel() activeDiscoverJob?.cancel()
lastRequestKey = null lastRequestKey = null
discoverSources = emptyList() discoverSources = emptyList()
lastDiscoverHideUnreleasedContent = null
_uiState.value = SearchUiState() _uiState.value = SearchUiState()
_discoverUiState.value = DiscoverUiState() _discoverUiState.value = DiscoverUiState()
} }
@ -128,6 +136,7 @@ object SearchRepository {
if (activeAddons.isEmpty()) { if (activeAddons.isEmpty()) {
activeDiscoverJob?.cancel() activeDiscoverJob?.cancel()
discoverSources = emptyList() discoverSources = emptyList()
lastDiscoverHideUnreleasedContent = null
log.d { "Discover refresh aborted: no active addons" } log.d { "Discover refresh aborted: no active addons" }
_discoverUiState.value = DiscoverUiState( _discoverUiState.value = DiscoverUiState(
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons, emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
@ -137,7 +146,12 @@ object SearchRepository {
val sources = buildDiscoverSources(activeAddons) val sources = buildDiscoverSources(activeAddons)
val current = _discoverUiState.value 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 { log.d {
"Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " + "Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " +
"genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}" "genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}"
@ -146,6 +160,7 @@ object SearchRepository {
} }
discoverSources = sources discoverSources = sources
lastDiscoverHideUnreleasedContent = hideUnreleasedContent
if (sources.isEmpty()) { if (sources.isEmpty()) {
activeDiscoverJob?.cancel() activeDiscoverJob?.cancel()
log.d { "Discover refresh found no compatible discover catalogs" } log.d { "Discover refresh found no compatible discover catalogs" }
@ -310,7 +325,7 @@ object SearchRepository {
type = type, type = type,
catalogId = catalogId, catalogId = catalogId,
search = query, search = query,
) ).withUnreleasedFilter()
val items = page.items val items = page.items
require(items.isNotEmpty()) { "No search results returned for $catalogName." } require(items.isNotEmpty()) { "No search results returned for $catalogName." }
@ -364,7 +379,7 @@ object SearchRepository {
catalogId = selectedCatalog.catalogId, catalogId = selectedCatalog.catalogId,
genre = current.selectedGenre, genre = current.selectedGenre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) ).withUnreleasedFilter()
}.fold( }.fold(
onSuccess = { page -> onSuccess = { page ->
val latest = _discoverUiState.value 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( private data class SearchCatalogRequest(
val addon: ManagedAddon, val addon: ManagedAddon,
val catalogId: String, 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.NuvioScreenHeader
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.addons.AddonRepository 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.MetaPreview
import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -88,6 +89,7 @@ fun SearchScreen(
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
@ -123,11 +125,11 @@ fun SearchScreen(
} }
} }
LaunchedEffect(addonRefreshKey) { LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
SearchRepository.refreshDiscover(addonsUiState.addons) SearchRepository.refreshDiscover(addonsUiState.addons)
} }
LaunchedEffect(query, addonRefreshKey) { LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
val normalizedQuery = query.trim() val normalizedQuery = query.trim()
if (normalizedQuery.isBlank()) { if (normalizedQuery.isBlank()) {
lastRequestedQuery = null 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_black
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description 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_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_poster_customization_description
import nuvio.composeapp.generated.resources.settings_appearance_section_display import nuvio.composeapp.generated.resources.settings_appearance_section_display
import nuvio.composeapp.generated.resources.settings_appearance_section_home import nuvio.composeapp.generated.resources.settings_appearance_section_home
@ -70,6 +72,9 @@ internal fun LazyListScope.appearanceSettingsContent(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
liquidGlassNativeTabBarSupported: Boolean,
liquidGlassNativeTabBarEnabled: Boolean,
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage, selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit, onAppLanguageSelected: (AppLanguage) -> Unit,
onContinueWatchingClick: () -> Unit, onContinueWatchingClick: () -> Unit,
@ -118,6 +123,16 @@ internal fun LazyListScope.appearanceSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = onAmoledToggle, 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) SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow( SettingsNavigationRow(
title = stringResource(Res.string.settings_appearance_app_language), 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.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description 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_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_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior 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_style_wide_description
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_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_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
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isVisible: Boolean, isVisible: Boolean,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean, upNextFromFurthestEpisode: Boolean,
useEpisodeThumbnails: Boolean,
showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean, showResumePromptOnLaunch: Boolean,
) { ) {
item { item {
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(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( SettingsSwitchRow(
title = stringResource(Res.string.settings_continue_watching_up_next_title), title = stringResource(Res.string.settings_continue_watching_up_next_title),
description = stringResource(Res.string.settings_continue_watching_up_next_description), description = stringResource(Res.string.settings_continue_watching_up_next_description),
@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, 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 com.nuvio.app.features.home.components.HomeEmptyStateCard
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_reset 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_message
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
@ -62,6 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
internal fun LazyListScope.homescreenSettingsContent( internal fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean, isTablet: Boolean,
heroEnabled: Boolean, heroEnabled: Boolean,
hideUnreleasedContent: Boolean,
items: List<HomeCatalogSettingsItem>, items: List<HomeCatalogSettingsItem>,
) { ) {
val selectedHeroSourceCount = items.count { it.heroSourceEnabled } val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
@ -87,6 +90,14 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, 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_episode_style_list_description
import nuvio.composeapp.generated.resources.settings_meta_episodes import nuvio.composeapp.generated.resources.settings_meta_episodes
import nuvio.composeapp.generated.resources.settings_meta_episodes_description 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_group_label
import nuvio.composeapp.generated.resources.settings_meta_more_like_this import nuvio.composeapp.generated.resources.settings_meta_more_like_this
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
selectedStyle = uiState.episodeCardStyle, selectedStyle = uiState.episodeCardStyle,
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle, 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() val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -74,6 +77,7 @@ fun HomescreenSettingsScreen(
homescreenSettingsContent( homescreenSettingsContent(
isTablet = false, isTablet = false,
heroEnabled = homescreenSettingsUiState.heroEnabled, heroEnabled = homescreenSettingsUiState.heroEnabled,
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
items = homescreenSettingsUiState.items, items = homescreenSettingsUiState.items,
) )
} }
@ -127,6 +131,9 @@ fun ContinueWatchingSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible, isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style, style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
} }

View file

@ -38,9 +38,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.AppTheme 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.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.PlatformBackHandler 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.addons.AddonRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaScreenSettingsUiState 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.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings 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.TmdbSettings
import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -92,6 +96,10 @@ fun SettingsScreen(
ThemeSettingsRepository.selectedTheme ThemeSettingsRepository.selectedTheme
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.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 selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
val tmdbSettings by remember { val tmdbSettings by remember {
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()
@ -109,6 +117,10 @@ fun SettingsScreen(
TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled TraktCommentsSettings.enabled
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val addonsUiState by remember { val addonsUiState by remember {
AddonRepository.initialize() AddonRepository.initialize()
AddonRepository.uiState AddonRepository.uiState
@ -129,6 +141,7 @@ fun SettingsScreen(
} }
} }
val homescreenSettingsUiState by remember { val homescreenSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val metaScreenSettingsUiState by remember { val metaScreenSettingsUiState by remember {
@ -184,6 +197,9 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme, onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled, onAmoledToggle = ThemeSettingsRepository::setAmoled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
@ -191,7 +207,9 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings, mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState, traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled, traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
homescreenItems = homescreenSettingsUiState.items, homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState, metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@ -224,6 +242,9 @@ fun SettingsScreen(
onThemeSelected = ThemeSettingsRepository::setTheme, onThemeSelected = ThemeSettingsRepository::setTheme,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled, onAmoledToggle = ThemeSettingsRepository::setAmoled,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
@ -231,7 +252,9 @@ fun SettingsScreen(
mdbListSettings = mdbListSettings, mdbListSettings = mdbListSettings,
traktAuthUiState = traktAuthUiState, traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled, traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
homescreenItems = homescreenSettingsUiState.items, homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState, metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@ -274,6 +297,9 @@ private fun MobileSettingsScreen(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
liquidGlassNativeTabBarSupported: Boolean,
liquidGlassNativeTabBarEnabled: Boolean,
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage, selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit, onAppLanguageSelected: (AppLanguage) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
@ -281,7 +307,9 @@ private fun MobileSettingsScreen(
mdbListSettings: MdbListSettings, mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState, traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean, traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean, homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>, homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState, metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@ -353,6 +381,9 @@ private fun MobileSettingsScreen(
onThemeSelected = onThemeSelected, onThemeSelected = onThemeSelected,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle, onAmoledToggle = onAmoledToggle,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = onAppLanguageSelected, onAppLanguageSelected = onAppLanguageSelected,
onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingClick = onContinueWatchingClick,
@ -367,6 +398,9 @@ private fun MobileSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible, isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style, style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
@ -387,6 +421,7 @@ private fun MobileSettingsScreen(
SettingsPage.Homescreen -> homescreenSettingsContent( SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = false, isTablet = false,
heroEnabled = homescreenHeroEnabled, heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
items = homescreenItems, items = homescreenItems,
) )
SettingsPage.MetaScreen -> metaScreenSettingsContent( SettingsPage.MetaScreen -> metaScreenSettingsContent(
@ -409,6 +444,7 @@ private fun MobileSettingsScreen(
SettingsPage.TraktAuthentication -> traktSettingsContent( SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false, isTablet = false,
uiState = traktAuthUiState, uiState = traktAuthUiState,
settingsUiState = traktSettingsUiState,
commentsEnabled = traktCommentsEnabled, commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
) )
@ -439,6 +475,9 @@ private fun TabletSettingsScreen(
onThemeSelected: (AppTheme) -> Unit, onThemeSelected: (AppTheme) -> Unit,
amoledEnabled: Boolean, amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit, onAmoledToggle: (Boolean) -> Unit,
liquidGlassNativeTabBarSupported: Boolean,
liquidGlassNativeTabBarEnabled: Boolean,
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
selectedAppLanguage: AppLanguage, selectedAppLanguage: AppLanguage,
onAppLanguageSelected: (AppLanguage) -> Unit, onAppLanguageSelected: (AppLanguage) -> Unit,
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
@ -446,7 +485,9 @@ private fun TabletSettingsScreen(
mdbListSettings: MdbListSettings, mdbListSettings: MdbListSettings,
traktAuthUiState: TraktAuthUiState, traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean, traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean, homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>, homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState, metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@ -519,6 +560,7 @@ private fun TabletSettingsScreen(
saveableStateHolder.SaveableStateProvider(page.name) { saveableStateHolder.SaveableStateProvider(page.name) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -526,7 +568,7 @@ private fun TabletSettingsScreen(
start = 40.dp, start = 40.dp,
top = topOffset, top = topOffset,
end = 40.dp, end = 40.dp,
bottom = 40.dp, bottom = 40.dp + bottomOverlayPadding,
), ),
verticalArrangement = Arrangement.spacedBy(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
) { ) {
@ -589,6 +631,9 @@ private fun TabletSettingsScreen(
onThemeSelected = onThemeSelected, onThemeSelected = onThemeSelected,
amoledEnabled = amoledEnabled, amoledEnabled = amoledEnabled,
onAmoledToggle = onAmoledToggle, onAmoledToggle = onAmoledToggle,
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
selectedAppLanguage = selectedAppLanguage, selectedAppLanguage = selectedAppLanguage,
onAppLanguageSelected = onAppLanguageSelected, onAppLanguageSelected = onAppLanguageSelected,
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
@ -603,6 +648,9 @@ private fun TabletSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible, isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style, style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
@ -623,6 +671,7 @@ private fun TabletSettingsScreen(
SettingsPage.Homescreen -> homescreenSettingsContent( SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = true, isTablet = true,
heroEnabled = homescreenHeroEnabled, heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
items = homescreenItems, items = homescreenItems,
) )
SettingsPage.MetaScreen -> metaScreenSettingsContent( SettingsPage.MetaScreen -> metaScreenSettingsContent(
@ -645,6 +694,7 @@ private fun TabletSettingsScreen(
SettingsPage.TraktAuthentication -> traktSettingsContent( SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = true, isTablet = true,
uiState = traktAuthUiState, uiState = traktAuthUiState,
settingsUiState = traktSettingsUiState,
commentsEnabled = traktCommentsEnabled, commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
) )

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.settings package com.nuvio.app.features.settings
import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.AppTheme
import com.nuvio.app.core.ui.NativeTabBridge
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -12,6 +13,9 @@ object ThemeSettingsRepository {
private val _amoledEnabled = MutableStateFlow(false) private val _amoledEnabled = MutableStateFlow(false)
val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow() val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow()
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow() val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
@ -30,6 +34,9 @@ object ThemeSettingsRepository {
hasLoaded = false hasLoaded = false
_selectedTheme.value = AppTheme.WHITE _selectedTheme.value = AppTheme.WHITE
_amoledEnabled.value = false _amoledEnabled.value = false
_liquidGlassNativeTabBarEnabled.value = false
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
NativeTabBridge.publishLiquidGlassEnabled(false)
_selectedAppLanguage.value = AppLanguage.ENGLISH _selectedAppLanguage.value = AppLanguage.ENGLISH
} }
@ -46,7 +53,11 @@ object ThemeSettingsRepository {
AppTheme.WHITE AppTheme.WHITE
} }
_selectedTheme.value = theme _selectedTheme.value = theme
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
_selectedAppLanguage.value = appLanguage _selectedAppLanguage.value = appLanguage
@ -57,6 +68,7 @@ object ThemeSettingsRepository {
if (_selectedTheme.value == theme) return if (_selectedTheme.value == theme) return
_selectedTheme.value = theme _selectedTheme.value = theme
ThemeSettingsStorage.saveSelectedTheme(theme.name) ThemeSettingsStorage.saveSelectedTheme(theme.name)
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
} }
fun setAmoled(enabled: Boolean) { fun setAmoled(enabled: Boolean) {
@ -66,6 +78,14 @@ object ThemeSettingsRepository {
ThemeSettingsStorage.saveAmoledEnabled(enabled) 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) { fun setAppLanguage(language: AppLanguage) {
ensureLoaded() ensureLoaded()
if (_selectedAppLanguage.value == language) return if (_selectedAppLanguage.value == language) return
@ -74,3 +94,13 @@ object ThemeSettingsRepository {
_selectedAppLanguage.value = language _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 saveSelectedTheme(themeName: String)
fun loadAmoledEnabled(): Boolean? fun loadAmoledEnabled(): Boolean?
fun saveAmoledEnabled(enabled: Boolean) fun saveAmoledEnabled(enabled: Boolean)
fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
fun loadSelectedAppLanguage(): String? fun loadSelectedAppLanguage(): String?
fun saveSelectedAppLanguage(languageCode: String) fun saveSelectedAppLanguage(languageCode: String)
fun applySelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String)

View file

@ -1,31 +1,57 @@
package com.nuvio.app.features.settings 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListScope 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.platform.LocalUriHandler
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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 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.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktConnectionMode 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 com.nuvio.app.features.trakt.traktBrandPainter
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel 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_approval_redirect
import nuvio.composeapp.generated.resources.settings_trakt_authentication import nuvio.composeapp.generated.resources.settings_trakt_authentication
import nuvio.composeapp.generated.resources.settings_trakt_comments 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_open_login
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description 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.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 import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.traktSettingsContent( internal fun LazyListScope.traktSettingsContent(
isTablet: Boolean, isTablet: Boolean,
uiState: TraktAuthUiState, uiState: TraktAuthUiState,
settingsUiState: TraktSettingsUiState,
commentsEnabled: Boolean, commentsEnabled: Boolean,
onCommentsEnabledChange: (Boolean) -> Unit, onCommentsEnabledChange: (Boolean) -> Unit,
) { ) {
@ -77,6 +126,62 @@ internal fun LazyListScope.traktSettingsContent(
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
TraktFeatureRows(
isTablet = isTablet,
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( SettingsSwitchRow(
title = stringResource(Res.string.settings_trakt_comments), title = stringResource(Res.string.settings_trakt_comments),
description = stringResource(Res.string.settings_trakt_comments_description), description = stringResource(Res.string.settings_trakt_comments_description),
@ -84,6 +189,352 @@ internal fun LazyListScope.traktSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = onCommentsEnabledChange, 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, posterShape = PosterShape.Poster,
description = recommendation.overview?.trim()?.takeIf(String::isNotBlank), description = recommendation.overview?.trim()?.takeIf(String::isNotBlank),
releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4), releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4),
rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate,
imdbRating = recommendation.voteAverage?.formatRating(), imdbRating = recommendation.voteAverage?.formatRating(),
) )
} }
@ -1087,6 +1088,7 @@ object TmdbMetadataService {
posterShape = PosterShape.Landscape, posterShape = PosterShape.Landscape,
description = part.overview?.trim()?.takeIf(String::isNotBlank), description = part.overview?.trim()?.takeIf(String::isNotBlank),
releaseInfo = part.releaseDate?.take(4), releaseInfo = part.releaseDate?.take(4),
rawReleaseDate = part.releaseDate,
imdbRating = part.voteAverage?.formatRating(), 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 SNAPSHOT_CACHE_TTL_MS = 60_000L
private const val LIST_TABS_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 FORCE_REFRESH_DEDUP_MS = 10_000L
private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240
data class TraktLibraryUiState( data class TraktLibraryUiState(
val listTabs: List<TraktListTab> = emptyList(), val listTabs: List<TraktListTab> = emptyList(),
@ -159,21 +160,20 @@ object TraktLibraryRepository {
errorMessage = null, errorMessage = null,
) )
} }
}.onFailure { error -> }
result.exceptionOrNull()?.let { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt library: ${error.message}" } log.w(error) { "Failed to refresh Trakt library" }
}.getOrNull() _uiState.value = _uiState.value.copy(
if (result == null) {
_uiState.value = current.copy(
isLoading = false, isLoading = false,
hasLoaded = true, hasLoaded = true,
errorMessage = getString(Res.string.trakt_library_load_failed), errorMessage = traktLibraryLoadErrorMessage(error),
) )
return return
} }
_uiState.value = result.copy( val snapshot = result.getOrThrow()
_uiState.value = snapshot.copy(
isLoading = false, isLoading = false,
hasLoaded = true, hasLoaded = true,
errorMessage = null, errorMessage = null,
@ -414,6 +414,27 @@ object TraktLibraryRepository {
TraktLibraryStorage.savePayload(json.encodeToString(payload)) 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> { private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
val watchlistTabs = listOf( val watchlistTabs = listOf(
TraktListTab( 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.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.details.MetaDetailsRepository 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.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.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv" 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 HISTORY_LIMIT = 250
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5 private const val METADATA_FETCH_CONCURRENCY = 5
@ -113,8 +118,8 @@ object TraktProgressRepository {
} }
scope.launch { scope.launch {
val historyEntries = runCatching { val completedEntries = runCatching {
fetchHistoryEntries(headers) fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
}.onFailure { error -> }.onFailure { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
@ -122,7 +127,7 @@ object TraktProgressRepository {
if (!isLatestRefreshRequest(requestId)) return@launch if (!isLatestRefreshRequest(requestId)) return@launch
val merged = mergeNewestByVideoId(playbackEntries + historyEntries) val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
isLoading = false, isLoading = false,
@ -345,12 +350,32 @@ object TraktProgressRepository {
mergeNewestByVideoId(completedEpisodes + completedMovies) 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> { private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>() val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
entries.forEach { rawEntry -> entries.forEach { rawEntry ->
val entry = rawEntry.normalizedCompletion() val entry = rawEntry.normalizedCompletion()
val existing = mergedByVideoId[entry.videoId] val existing = mergedByVideoId[entry.videoId]
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
mergedByVideoId[entry.videoId] = entry mergedByVideoId[entry.videoId] = entry
} }
} }
@ -360,6 +385,18 @@ object TraktProgressRepository {
.sortedByDescending { it.lastUpdatedEpochMs } .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( private fun mergeEntriesPreferRichMetadata(
current: List<WatchProgressEntry>, current: List<WatchProgressEntry>,
hydrated: List<WatchProgressEntry>, hydrated: List<WatchProgressEntry>,
@ -499,6 +536,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
progressPercent = progressPercent, progressPercent = progressPercent,
source = WatchProgressSourceTraktPlayback,
).normalizedCompletion() ).normalizedCompletion()
} }
@ -533,6 +571,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
progressPercent = progressPercent, progressPercent = progressPercent,
source = WatchProgressSourceTraktPlayback,
).normalizedCompletion() ).normalizedCompletion()
} }
@ -564,6 +603,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
isCompleted = true, isCompleted = true,
progressPercent = 100f, progressPercent = 100f,
source = WatchProgressSourceTraktHistory,
) )
} }
@ -583,6 +623,73 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
isCompleted = true, isCompleted = true,
progressPercent = 100f, 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 { private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
val compactDigits = isoDate isoDate
?.filter(Char::isDigit) ?.takeIf { it.isNotBlank() }
?.take(14) ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?.takeIf { it.length >= 8 } ?.let { return it }
?.padEnd(14, '0')
?.toLongOrNull()
if (compactDigits != null) return compactDigits
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
} }
} }
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
@SerialName("movie") val movie: TraktMedia? = null, @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 @Serializable
private data class TraktMedia( private data class TraktMedia(
@SerialName("title") val title: String? = null, @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 package com.nuvio.app.features.watched
import com.nuvio.app.features.home.MetaPreview 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.WatchingContentRef
import com.nuvio.app.features.watching.domain.watchedKey import com.nuvio.app.features.watching.domain.watchedKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
val WatchedItem.isEpisode: Boolean val WatchedItem.isEpisode: Boolean
get() = season != null && episode != null 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( fun watchedItemKey(
type: String, type: String,
id: String, id: String,
@ -47,3 +85,5 @@ fun watchedItemKey(
episodeNumber = episode, 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.details.MetaDetails
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository 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.SupabaseWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
@ -42,8 +45,8 @@ object WatchedRepository {
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf() private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
private fun activeSyncAdapter(): WatchedSyncAdapter = private fun activePullSyncAdapter(): WatchedSyncAdapter =
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -72,21 +75,27 @@ object WatchedRepository {
val items = runCatching { val items = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload).items json.decodeFromString<StoredWatchedPayload>(payload).items
}.getOrDefault(emptyList()) }.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() publish()
} }
suspend fun pullFromServer(profileId: Int) { suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
currentProfileId = profileId currentProfileId = profileId
runCatching { runCatching {
val serverItems = activeSyncAdapter().pull( val serverItems = activePullSyncAdapter().pull(
profileId = profileId, profileId = profileId,
pageSize = watchedItemsPageSize, pageSize = watchedItemsPageSize,
) )
itemsByKey = serverItems itemsByKey = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap() .toMutableMap()
hasLoaded = true hasLoaded = true
@ -203,7 +212,7 @@ object WatchedRepository {
runCatching { runCatching {
if (items.isEmpty()) return@runCatching if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
activeSyncAdapter().push(profileId = profileId, items = items) pushToActiveTargets(profileId = profileId, items = items)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to push watched items" } log.e(e) { "Failed to push watched items" }
} }
@ -215,7 +224,7 @@ object WatchedRepository {
runCatching { runCatching {
if (items.isEmpty()) return@runCatching if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
activeSyncAdapter().delete(profileId = profileId, items = items) deleteFromActiveTargets(profileId = profileId, items = items)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to push watched item delete" } log.e(e) { "Failed to push watched item delete" }
} }
@ -223,7 +232,9 @@ object WatchedRepository {
} }
private fun publish() { private fun publish() {
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs } val items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs }
_uiState.value = WatchedUiState( _uiState.value = WatchedUiState(
items = items, items = items,
watchedKeys = items.mapTo(linkedSetOf()) { watchedKeys = items.mapTo(linkedSetOf()) {
@ -238,9 +249,55 @@ object WatchedRepository {
currentProfileId, currentProfileId,
json.encodeToString( json.encodeToString(
StoredWatchedPayload( 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.details.MetaVideo
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.watched.WatchedItem 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.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry 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.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.WatchingProgressRecord
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
object WatchingState { object WatchingState {
@ -59,7 +61,9 @@ object WatchingState {
add(WatchingContentRef(type = item.type, id = item.id)) 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) val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
return contentRefs.mapNotNull { content -> return contentRefs.mapNotNull { content ->
latestCompletedSeriesEpisode( latestCompletedSeriesEpisode(
@ -73,21 +77,9 @@ object WatchingState {
fun visibleContinueWatchingEntries( fun visibleContinueWatchingEntries(
progressEntries: List<WatchProgressEntry>, progressEntries: List<WatchProgressEntry>,
@Suppress("UNUSED_PARAMETER")
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>, latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
): List<WatchProgressEntry> { ): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
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 }
}
} }
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id), content = WatchingContentRef(type = type, id = id),
seasonNumber = season, seasonNumber = season,
episodeNumber = episode, episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
) )

View file

@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> { override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) } val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) 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( ProgressSyncRecord(
contentId = entry.contentId, contentId = entry.contentId,
contentType = entry.contentType, contentType = entry.contentType,
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
lastWatched = entry.lastWatched, lastWatched = entry.lastWatched,
) )
} }
return records
} }
override suspend fun push( 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.core.network.SupabaseProvider
import com.nuvio.app.features.watched.WatchedItem 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.postgrest
import io.github.jan.supabase.postgrest.rpc import io.github.jan.supabase.postgrest.rpc
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
name = syncItem.title, name = syncItem.title,
season = syncItem.season, season = syncItem.season,
episode = syncItem.episode, episode = syncItem.episode,
markedAtEpochMs = syncItem.watchedAt, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
) )
} }
} }
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
title = item.name, title = item.name,
season = item.season, season = item.season,
episode = item.episode, episode = item.episode,
watchedAt = item.markedAtEpochMs, watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
) )
} }
val params = buildJsonObject { 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.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService 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.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
} }
private fun rankedTimestamp(isoDate: String?): Long { private fun rankedTimestamp(isoDate: String?): Long {
val digits = isoDate return isoDate
?.filter(Char::isDigit) ?.takeIf { it.isNotBlank() }
?.take(14) ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?.takeIf { it.length >= 8 } ?: 0L
?.padEnd(14, '0')
?.toLongOrNull()
return digits ?: 0L
} }
private fun epochMsToIso(epochMs: Long): String { private fun epochMsToIso(epochMs: Long): String {
// Convert to a compact ISO 8601 UTC string. val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places, if (normalizedEpochMs <= 0L) return "unknown"
// or a real epoch-ms. We only send when it looks like real epoch-ms. if (normalizedEpochMs < 10_000_000_000L) return "unknown"
if (epochMs <= 0L) return "unknown"
if (epochMs < 10_000_000_000L) {
// Looks like seconds-based or ranked timestamp — send unknown
return "unknown"
}
// Real epoch ms → simple ISO via arithmetic // Real epoch ms → simple ISO via arithmetic
val totalSeconds = epochMs / 1000 val totalSeconds = normalizedEpochMs / 1000
val s = (totalSeconds % 60).toInt() val s = (totalSeconds % 60).toInt()
val m = ((totalSeconds / 60) % 60).toInt() val m = ((totalSeconds / 60) % 60).toInt()
val h = ((totalSeconds / 3600) % 24).toInt() val h = ((totalSeconds / 3600) % 24).toInt()

View file

@ -19,6 +19,8 @@ data class CachedNextUpItem(
val episodeTitle: String? = null, val episodeTitle: String? = null,
val episodeThumbnail: String? = null, val episodeThumbnail: String? = null,
val pauseDescription: String? = null, val pauseDescription: String? = null,
val released: String? = null,
val hasAired: Boolean = true,
val lastWatched: Long, val lastWatched: Long,
val sortTimestamp: Long, val sortTimestamp: Long,
val seedSeason: Int? = null, 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -13,6 +14,12 @@ private data class StoredContinueWatchingPreferences(
val isVisible: Boolean = true, val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true, 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 dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
) )
@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository {
isVisible: Boolean, isVisible: Boolean,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean, upNextFromFurthestEpisode: Boolean,
useEpisodeThumbnails: Boolean = true,
showUnairedNextUp: Boolean = true,
blurNextUp: Boolean = false,
dismissedNextUpKeys: Set<String>, dismissedNextUpKeys: Set<String>,
) { ) {
ensureLoaded() ensureLoaded()
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = isVisible, isVisible = isVisible,
style = style, style = style,
upNextFromFurthestEpisode = upNextFromFurthestEpisode, upNextFromFurthestEpisode = upNextFromFurthestEpisode,
useEpisodeThumbnails = useEpisodeThumbnails,
showUnairedNextUp = showUnairedNextUp,
blurNextUp = blurNextUp,
dismissedNextUpKeys = dismissedNextUpKeys dismissedNextUpKeys = dismissedNextUpKeys
.map(String::trim) .map(String::trim)
.filter(String::isNotBlank) .filter(String::isNotBlank)
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = stored.isVisible, isVisible = stored.isVisible,
style = stored.style, style = stored.style,
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode, upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
useEpisodeThumbnails = stored.useEpisodeThumbnails,
showUnairedNextUp = stored.showUnairedNextUp,
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys, dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch, showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
) )
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
persist() 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) { fun addDismissedNextUpKey(key: String) {
ensureLoaded() ensureLoaded()
val normalizedKey = key.trim() val normalizedKey = key.trim()
@ -139,6 +173,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = _uiState.value.isVisible, isVisible = _uiState.value.isVisible,
style = _uiState.value.style, style = _uiState.value.style,
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode, upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
showUnairedNextUp = _uiState.value.showUnairedNextUp,
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, 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 com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlinx.serialization.Serializable 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 @Serializable
enum class ContinueWatchingSectionStyle { enum class ContinueWatchingSectionStyle {
@ -37,6 +42,7 @@ data class WatchProgressEntry(
val lastSourceUrl: String? = null, val lastSourceUrl: String? = null,
val isCompleted: Boolean = false, val isCompleted: Boolean = false,
val progressPercent: Float? = null, val progressPercent: Float? = null,
val source: String = WatchProgressSourceLocal,
) { ) {
val normalizedProgressPercent: Float? val normalizedProgressPercent: Float?
get() = progressPercent?.coerceIn(0f, 100f) get() = progressPercent?.coerceIn(0f, 100f)
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
val episodeTitle: String? = null, val episodeTitle: String? = null,
val episodeThumbnail: String? = null, val episodeThumbnail: String? = null,
val pauseDescription: String? = null, val pauseDescription: String? = null,
val released: String? = null,
val isNextUp: Boolean = false, val isNextUp: Boolean = false,
val nextUpSeedSeasonNumber: Int? = null, val nextUpSeedSeasonNumber: Int? = null,
val nextUpSeedEpisodeNumber: Int? = null, val nextUpSeedEpisodeNumber: Int? = null,
@ -163,6 +170,9 @@ data class ContinueWatchingPreferencesUiState(
val isVisible: Boolean = true, val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true, val upNextFromFurthestEpisode: Boolean = true,
val useEpisodeThumbnails: Boolean = true,
val showUnairedNextUp: Boolean = true,
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(), val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
) )
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
episodeTitle = normalizedEntry.episodeTitle, episodeTitle = normalizedEntry.episodeTitle,
episodeThumbnail = normalizedEntry.episodeThumbnail, episodeThumbnail = normalizedEntry.episodeThumbnail,
pauseDescription = normalizedEntry.pauseDescription, pauseDescription = normalizedEntry.pauseDescription,
released = null,
isNextUp = false, isNextUp = false,
nextUpSeedSeasonNumber = null, nextUpSeedSeasonNumber = null,
nextUpSeedEpisodeNumber = null, nextUpSeedEpisodeNumber = null,
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
episodeTitle = nextEpisode.title, episodeTitle = nextEpisode.title,
episodeThumbnail = nextEpisode.thumbnail, episodeThumbnail = nextEpisode.thumbnail,
pauseDescription = nextEpisode.overview, pauseDescription = nextEpisode.overview,
released = nextEpisode.released,
isNextUp = true, isNextUp = true,
nextUpSeedSeasonNumber = seasonNumber, nextUpSeedSeasonNumber = seasonNumber,
nextUpSeedEpisodeNumber = episodeNumber, 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.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktProgressRepository 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.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
@ -37,7 +39,11 @@ object WatchProgressRepository {
init { init {
syncScope.launch { syncScope.launch {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) { if (shouldUseTraktProgressSource(
isAuthenticated = authenticated,
source = TraktSettingsRepository.uiState.value.watchProgressSource,
)
) {
runCatching { TraktProgressRepository.refreshNow() } runCatching { TraktProgressRepository.refreshNow() }
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } .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 { syncScope.launch {
TraktProgressRepository.uiState.collectLatest { TraktProgressRepository.uiState.collectLatest {
if (TraktAuthRepository.isAuthenticated.value) { if (shouldUseTraktProgress()) {
publish() publish()
} }
} }
@ -56,19 +76,21 @@ object WatchProgressRepository {
fun ensureLoaded() { fun ensureLoaded() {
TraktAuthRepository.ensureLoaded() TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded() TraktProgressRepository.ensureLoaded()
if (hasLoaded) return if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId) loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) { if (shouldUseTraktProgress()) {
TraktProgressRepository.refreshAsync() TraktProgressRepository.refreshAsync()
} }
} }
fun onProfileChanged(profileId: Int) { fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return if (profileId == currentProfileId && hasLoaded) return
TraktSettingsRepository.onProfileChanged()
loadFromDisk(profileId) loadFromDisk(profileId)
TraktProgressRepository.onProfileChanged() TraktProgressRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) { if (shouldUseTraktProgress()) {
TraktProgressRepository.refreshAsync() TraktProgressRepository.refreshAsync()
} }
} }
@ -79,6 +101,7 @@ object WatchProgressRepository {
currentProfileId = 1 currentProfileId = 1
entriesByVideoId.clear() entriesByVideoId.clear()
TraktProgressRepository.clearLocalState() TraktProgressRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
_uiState.value = WatchProgressUiState() _uiState.value = WatchProgressUiState()
} }
@ -98,9 +121,14 @@ object WatchProgressRepository {
} }
suspend fun pullFromServer(profileId: Int) { suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
currentProfileId = profileId currentProfileId = profileId
if (TraktAuthRepository.isAuthenticated.value) { val useTraktProgress = shouldUseTraktProgress()
if (useTraktProgress) {
runCatching { TraktProgressRepository.refreshNow() } runCatching { TraktProgressRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish() publish()
@ -368,7 +396,6 @@ object WatchProgressRepository {
} }
private fun pushScrobbleToServer(entry: WatchProgressEntry) { private fun pushScrobbleToServer(entry: WatchProgressEntry) {
if (shouldUseTraktProgress()) return
syncScope.launch { syncScope.launch {
runCatching { runCatching {
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
@ -394,8 +421,9 @@ object WatchProgressRepository {
private fun publish() { private fun publish() {
val entries = currentEntries() val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
_uiState.value = WatchProgressUiState( _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> { private fun currentEntries(): List<WatchProgressEntry> {
return if (shouldUseTraktProgress()) { return if (shouldUseTraktProgress()) {

View file

@ -67,15 +67,50 @@ internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): Watc
internal fun List<WatchProgressEntry>.continueWatchingEntries( internal fun List<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit, limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> { ): List<WatchProgressEntry> {
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
val domainEntries = continueWatchingProgressEntries( val domainEntries = continueWatchingProgressEntries(
progressRecords = map(WatchProgressEntry::toDomainProgressRecord), progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
limit = limit, limit = limit,
) )
val ids = domainEntries.map { record -> record.videoId }.toSet() 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 } .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 = private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
normalizedCompletion().let { entry -> normalizedCompletion().let { entry ->
WatchingProgressRecord( WatchingProgressRecord(

View file

@ -49,4 +49,26 @@ class HomeCatalogParserTest {
result.items.map { it.stableKey() }, 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.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -60,6 +61,91 @@ class HomeScreenTest {
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) 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( private fun progressEntry(
videoId: String, videoId: String,
title: String, title: String,
@ -100,4 +186,8 @@ class HomeScreenTest {
durationMs = 0L, durationMs = 0L,
progressFraction = 0f, 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 package com.nuvio.app.features.library
import com.nuvio.app.features.home.PosterShape 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -37,4 +39,34 @@ class LibraryRepositoryTest {
assertEquals(PosterShape.Poster, preview.posterShape) assertEquals(PosterShape.Poster, preview.posterShape)
assertEquals("banner", preview.banner) 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 }) 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 @Test
fun `codec normalizes completed entries inferred from percent`() { fun `codec normalizes completed entries inferred from percent`() {
val payload = WatchProgressCodec.encodeEntries( val payload = WatchProgressCodec.encodeEntries(
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
durationMs: Long = 1_000_000L, durationMs: Long = 1_000_000L,
isCompleted: Boolean = false, isCompleted: Boolean = false,
progressPercent: Float? = null, progressPercent: Float? = null,
source: String = WatchProgressSourceLocal,
): WatchProgressEntry = ): WatchProgressEntry =
WatchProgressEntry( WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
lastUpdatedEpochMs = lastUpdatedEpochMs, lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted, isCompleted = isCompleted,
progressPercent = progressPercent, progressPercent = progressPercent,
source = source,
) )
} }

View file

@ -45,6 +45,8 @@ internal actual object PlatformLocalAccountDataCleaner {
"mdblist_use_audience", "mdblist_use_audience",
"trakt_auth_payload", "trakt_auth_payload",
"trakt_library_payload", "trakt_library_payload",
"trakt_settings_payload",
"collections_payload",
) )
actual fun wipe() { 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 { actual object ThemeSettingsStorage {
private const val selectedThemeKey = "selected_theme" private const val selectedThemeKey = "selected_theme"
private const val amoledEnabledKey = "amoled_enabled" private const val amoledEnabledKey = "amoled_enabled"
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
private const val selectedAppLanguageKey = "selected_app_language" 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 val globalSyncKeys = listOf(selectedAppLanguageKey)
actual fun loadSelectedTheme(): String? = actual fun loadSelectedTheme(): String? =
@ -38,6 +43,23 @@ actual object ThemeSettingsStorage {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey)) 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? { actual fun loadSelectedAppLanguage(): String? {
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
if (value != null) return value if (value != null) return value
@ -65,6 +87,7 @@ actual object ThemeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject { actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
} }
@ -78,6 +101,7 @@ actual object ThemeSettingsStorage {
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) 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 CURRENT_PROJECT_VERSION=54
MARKETING_VERSION=0.1.13 MARKETING_VERSION=0.1.0

View file

@ -2,8 +2,316 @@ import UIKit
import SwiftUI import SwiftUI
import ComposeApp 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 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) { init(contentController: UIViewController) {
self.contentController = contentController self.contentController = contentController
@ -20,17 +328,45 @@ final class RootComposeViewController: UIViewController {
view.backgroundColor = .black view.backgroundColor = .black
contentController.view.backgroundColor = .black contentController.view.backgroundColor = .black
UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey)
addChild(contentController) addChild(contentController)
view.addSubview(contentController.view) view.addSubview(contentController.view)
contentController.view.translatesAutoresizingMaskIntoConstraints = false contentController.view.translatesAutoresizingMaskIntoConstraints = false
let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
self.contentBottomToViewBottom = bottomToViewBottom
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentController.view.topAnchor.constraint(equalTo: view.topAnchor), contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), bottomToViewBottom,
]) ])
contentController.didMove(toParent: self) 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? { override var childForHomeIndicatorAutoHidden: UIViewController? {
@ -88,6 +424,210 @@ final class RootComposeViewController: UIViewController {
return nil 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 { struct ComposeView: UIViewControllerRepresentable {