mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +00:00
Merge branch 'cmp-rewrite' into trailer-fullscreen-player
This commit is contained in:
commit
a6d5440945
84 changed files with 3991 additions and 443 deletions
|
|
@ -76,6 +76,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/details").apply {
|
||||
mkdirs()
|
||||
resolve("ImdbEpisodeRatingsConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.features.details
|
||||
|
|
||||
|object ImdbEpisodeRatingsConfig {
|
||||
| const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}"
|
||||
| const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||
mkdirs()
|
||||
resolve("AppVersionConfig.kt").writeText(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage
|
|||
import com.nuvio.app.features.trakt.TraktAuthStorage
|
||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
||||
|
|
@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
|
|||
TraktAuthStorage.initialize(applicationContext)
|
||||
TraktCommentsStorage.initialize(applicationContext)
|
||||
TraktLibraryStorage.initialize(applicationContext)
|
||||
TraktSettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
ResumePromptStorage.initialize(applicationContext)
|
||||
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_mdblist_settings",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_trakt_library",
|
||||
"nuvio_trakt_settings",
|
||||
"nuvio_watched",
|
||||
"nuvio_stream_link_cache",
|
||||
"nuvio_continue_watching_preferences",
|
||||
"nuvio_episode_release_notifications",
|
||||
"nuvio_episode_release_notifications_platform",
|
||||
"nuvio_watch_progress",
|
||||
"nuvio_collections",
|
||||
"nuvio_plugins",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -17,8 +17,13 @@ actual object ThemeSettingsStorage {
|
|||
private const val preferencesName = "nuvio_theme_settings"
|
||||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||
private const val selectedAppLanguageKey = "selected_app_language"
|
||||
private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey)
|
||||
private val profileScopedSyncKeys = listOf(
|
||||
selectedThemeKey,
|
||||
amoledEnabledKey,
|
||||
liquidGlassNativeTabBarEnabledKey,
|
||||
)
|
||||
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
|
@ -51,6 +56,19 @@ actual object ThemeSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? =
|
||||
preferences?.let { prefs ->
|
||||
val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
|
||||
if (prefs.contains(key)) prefs.getBoolean(key, false) else null
|
||||
}
|
||||
|
||||
actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSelectedAppLanguage(): String? {
|
||||
val value = preferences?.getString(selectedAppLanguageKey, null)
|
||||
if (value != null) return value
|
||||
|
|
@ -75,6 +93,7 @@ actual object ThemeSettingsStorage {
|
|||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +105,7 @@ actual object ThemeSettingsStorage {
|
|||
|
||||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -449,6 +449,8 @@
|
|||
<string name="settings_appearance_app_language">App Language</string>
|
||||
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
|
||||
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
|
||||
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
|
||||
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
|
||||
<string name="settings_appearance_section_display">DISPLAY</string>
|
||||
<string name="settings_appearance_section_home">HOME</string>
|
||||
|
|
@ -475,6 +477,8 @@
|
|||
<string name="settings_homescreen_selected_count">%1$d of %2$d selected</string>
|
||||
<string name="settings_homescreen_show_hero">Show Hero Section</string>
|
||||
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
|
||||
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
|
||||
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
||||
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
|
||||
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
|
||||
<string name="settings_homescreen_visible">Visible</string>
|
||||
|
|
@ -506,6 +510,10 @@
|
|||
<string name="settings_show_secret">Show value</string>
|
||||
<string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
|
||||
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
|
||||
<string name="settings_continue_watching_blur_next_up_description">Blur next episode thumbnails in Continue Watching to avoid spoilers.</string>
|
||||
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
|
||||
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
|
||||
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
|
||||
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
|
||||
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
|
||||
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
|
||||
|
|
@ -518,6 +526,8 @@
|
|||
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
|
||||
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
|
||||
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
|
||||
<string name="settings_continue_watching_use_episode_thumbnails_description">Prefer episode thumbnails when available.</string>
|
||||
<string name="settings_continue_watching_use_episode_thumbnails_title">Prefer Episode Thumbnails in Continue Watching</string>
|
||||
<string name="settings_content_discovery_section_home">HOME</string>
|
||||
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
||||
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
|
||||
|
|
@ -557,6 +567,8 @@
|
|||
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
|
||||
<string name="settings_meta_episodes">Episodes</string>
|
||||
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
|
||||
<string name="settings_meta_group_label">Group %1$d</string>
|
||||
<string name="settings_meta_more_like_this">More like this</string>
|
||||
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
|
||||
|
|
@ -783,6 +795,28 @@
|
|||
<string name="settings_trakt_open_login">Open Trakt Login</string>
|
||||
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
|
||||
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
|
||||
<string name="trakt_library_source_title">Library Source</string>
|
||||
<string name="trakt_library_source_subtitle">Choose which library to use for saving and viewing your collection</string>
|
||||
<string name="trakt_library_source_dialog_title">Library Source</string>
|
||||
<string name="trakt_library_source_dialog_subtitle">Choose where to save and manage your library items</string>
|
||||
<string name="trakt_library_source_trakt">Trakt</string>
|
||||
<string name="trakt_library_source_nuvio">Nuvio Library</string>
|
||||
<string name="trakt_library_source_trakt_selected">Trakt library selected</string>
|
||||
<string name="trakt_library_source_nuvio_selected">Nuvio library selected</string>
|
||||
<string name="trakt_watch_progress_title">Watch Progress</string>
|
||||
<string name="trakt_watch_progress_subtitle">Choose which progress source powers resume and continue watching</string>
|
||||
<string name="trakt_watch_progress_dialog_title">Watch Progress</string>
|
||||
<string name="trakt_watch_progress_dialog_subtitle">Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active.</string>
|
||||
<string name="trakt_watch_progress_source_trakt">Trakt</string>
|
||||
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
|
||||
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
|
||||
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
|
||||
<string name="trakt_continue_watching_window">Continue Watching Window</string>
|
||||
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
|
||||
<string name="trakt_cw_window_title">Continue Watching Window</string>
|
||||
<string name="trakt_cw_window_subtitle">Choose how much Trakt activity should appear in continue watching.</string>
|
||||
<string name="trakt_all_history">All history</string>
|
||||
<string name="trakt_days_format">%1$d days</string>
|
||||
<string name="source_audience_score">Audience Score</string>
|
||||
<string name="source_imdb">IMDb</string>
|
||||
<string name="source_letterboxd">Letterboxd</string>
|
||||
|
|
@ -973,9 +1007,14 @@
|
|||
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
|
||||
<string name="profile_avatar_options_pending">Avatar options will appear here when the catalog loads.</string>
|
||||
<string name="profile_avatar_selected">Avatar: %1$s</string>
|
||||
<string name="profile_avatar_url_invalid">Enter a valid http:// or https:// image URL.</string>
|
||||
<string name="profile_choose_avatar">Choose an avatar</string>
|
||||
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
|
||||
<string name="profile_create_profile">Create Profile</string>
|
||||
<string name="profile_custom_avatar_selected">Custom avatar URL selected.</string>
|
||||
<string name="profile_custom_avatar_url">Custom avatar URL</string>
|
||||
<string name="profile_custom_avatar_url_description">Paste an image link, or leave this empty to use the built-in avatar catalog.</string>
|
||||
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="profile_delete_confirm_message">All data for "%1$s" will be permanently deleted.</string>
|
||||
<string name="profile_delete_title">Delete Profile</string>
|
||||
<string name="profile_edit_add_title">Add Profile</string>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -60,6 +61,8 @@ import androidx.lifecycle.Lifecycle
|
|||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
|
|
@ -92,6 +95,10 @@ import com.nuvio.app.core.ui.NuvioToastController
|
|||
import com.nuvio.app.core.ui.NuvioFloatingPrompt
|
||||
import com.nuvio.app.core.ui.TraktListPickerDialog
|
||||
import com.nuvio.app.core.ui.NuvioTheme
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
import com.nuvio.app.core.ui.NativeNavigationTab
|
||||
import com.nuvio.app.core.ui.NativeTabBridge
|
||||
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
|
||||
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
||||
import com.nuvio.app.features.auth.AuthScreen
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
|
|
@ -122,11 +129,13 @@ import com.nuvio.app.features.player.PlayerRoute
|
|||
import com.nuvio.app.features.player.PlayerScreen
|
||||
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
||||
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
||||
import com.nuvio.app.features.profiles.AvatarRepository
|
||||
import com.nuvio.app.features.profiles.NuvioProfile
|
||||
import com.nuvio.app.features.profiles.ProfileEditScreen
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
||||
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
||||
import com.nuvio.app.features.profiles.profileAvatarImageUrl
|
||||
import com.nuvio.app.features.search.SearchScreen
|
||||
import com.nuvio.app.features.settings.SettingsScreen
|
||||
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
||||
|
|
@ -152,8 +161,6 @@ import com.nuvio.app.features.streams.StreamsRepository
|
|||
import com.nuvio.app.features.streams.StreamsScreen
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||
|
|
@ -262,6 +269,20 @@ enum class AppScreenTab {
|
|||
Settings,
|
||||
}
|
||||
|
||||
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
|
||||
AppScreenTab.Home -> NativeNavigationTab.Home
|
||||
AppScreenTab.Search -> NativeNavigationTab.Search
|
||||
AppScreenTab.Library -> NativeNavigationTab.Library
|
||||
AppScreenTab.Settings -> NativeNavigationTab.Settings
|
||||
}
|
||||
|
||||
private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
|
||||
NativeNavigationTab.Home -> AppScreenTab.Home
|
||||
NativeNavigationTab.Search -> AppScreenTab.Search
|
||||
NativeNavigationTab.Library -> AppScreenTab.Library
|
||||
NativeNavigationTab.Settings -> AppScreenTab.Settings
|
||||
}
|
||||
|
||||
private enum class AppGateScreen {
|
||||
Loading,
|
||||
Auth,
|
||||
|
|
@ -295,13 +316,36 @@ fun App() {
|
|||
LaunchedEffect(Unit) {
|
||||
NetworkStatusRepository.ensureStarted()
|
||||
ProfileRepository.loadCachedProfiles()
|
||||
AvatarRepository.fetchAvatars()
|
||||
}
|
||||
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by remember {
|
||||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(
|
||||
profileState.activeProfile?.profileIndex,
|
||||
profileState.activeProfile?.name,
|
||||
profileState.activeProfile?.avatarColorHex,
|
||||
profileState.activeProfile?.avatarId,
|
||||
profileState.activeProfile?.avatarUrl,
|
||||
profileAvatars,
|
||||
) {
|
||||
val activeProfile = profileState.activeProfile
|
||||
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
|
||||
profileAvatars.find { it.id == avatarId }
|
||||
}
|
||||
NativeTabBridge.publishProfileTabIcon(
|
||||
name = activeProfile?.name,
|
||||
avatarColorHex = activeProfile?.avatarColorHex,
|
||||
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
|
||||
avatarBackgroundColorHex = avatarItem?.bgColor,
|
||||
)
|
||||
}
|
||||
|
||||
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
|
||||
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||
var isNewProfile by remember { mutableStateOf(false) }
|
||||
|
|
@ -468,6 +512,11 @@ private fun MainAppContent(
|
|||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarEnabled by remember {
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
|
||||
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
||||
|
|
@ -486,10 +535,6 @@ private fun MainAppContent(
|
|||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val traktAuthUiState by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by remember {
|
||||
|
|
@ -508,7 +553,7 @@ private fun MainAppContent(
|
|||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
|
||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
||||
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
||||
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
||||
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
@ -521,6 +566,42 @@ private fun MainAppContent(
|
|||
.sorted()
|
||||
}
|
||||
|
||||
LaunchedEffect(nativeRequestedTab) {
|
||||
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
|
||||
selectedTab = nativeRequestedTab.toAppScreenTab()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
navController,
|
||||
liquidGlassNativeTabBarSupported,
|
||||
liquidGlassNativeTabBarEnabled,
|
||||
initialHomeReady,
|
||||
) {
|
||||
fun publishNativeTabVisibilityForCurrentRoute() {
|
||||
val visible = liquidGlassNativeTabBarSupported &&
|
||||
liquidGlassNativeTabBarEnabled &&
|
||||
initialHomeReady &&
|
||||
navController.currentDestination?.hasRoute<TabsRoute>() == true
|
||||
NativeTabBridge.publishTabBarVisible(visible)
|
||||
}
|
||||
|
||||
val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ ->
|
||||
publishNativeTabVisibilityForCurrentRoute()
|
||||
}
|
||||
|
||||
publishNativeTabVisibilityForCurrentRoute()
|
||||
navController.addOnDestinationChangedListener(destinationChangedListener)
|
||||
onDispose {
|
||||
navController.removeOnDestinationChangedListener(destinationChangedListener)
|
||||
NativeTabBridge.publishTabBarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
NetworkStatusRepository.ensureStarted()
|
||||
EpisodeReleaseNotificationsRepository.refreshAsync()
|
||||
|
|
@ -892,6 +973,8 @@ private fun MainAppContent(
|
|||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val isTabletLayout = maxWidth >= 768.dp
|
||||
val useNativeBottomTabs =
|
||||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||
profileSwitchLoading = true
|
||||
selectedTab = AppScreenTab.Home
|
||||
|
|
@ -906,7 +989,7 @@ private fun MainAppContent(
|
|||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
bottomBar = {
|
||||
if (!isTabletLayout) {
|
||||
if (!isTabletLayout && !useNativeBottomTabs) {
|
||||
NuvioNavigationBar {
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
|
|
@ -942,58 +1025,62 @@ private fun MainAppContent(
|
|||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
selectedTab = selectedTab,
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
onPosterLongClick = { meta ->
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedPosterForActions = meta
|
||||
},
|
||||
onLibraryPosterClick = { item ->
|
||||
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
||||
},
|
||||
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
navController.navigate(PluginsSettingsRoute)
|
||||
}
|
||||
},
|
||||
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
||||
onSupportersContributorsSettingsClick = {
|
||||
navController.navigate(SupportersContributorsSettingsRoute)
|
||||
},
|
||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||
{
|
||||
appUpdaterController.checkForUpdates(
|
||||
force = true,
|
||||
showNoUpdateFeedback = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||
},
|
||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
|
||||
) {
|
||||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
selectedTab = selectedTab,
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
onPosterLongClick = { meta ->
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedPosterForActions = meta
|
||||
},
|
||||
onLibraryPosterClick = { item ->
|
||||
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
||||
},
|
||||
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
navController.navigate(PluginsSettingsRoute)
|
||||
}
|
||||
},
|
||||
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
||||
onSupportersContributorsSettingsClick = {
|
||||
navController.navigate(SupportersContributorsSettingsRoute)
|
||||
},
|
||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||
{
|
||||
appUpdaterController.checkForUpdates(
|
||||
force = true,
|
||||
showNoUpdateFeedback = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||
},
|
||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (isTabletLayout) {
|
||||
if (isTabletLayout && !useNativeBottomTabs) {
|
||||
TabletFloatingTopBar(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
|
|
@ -1664,12 +1751,12 @@ private fun MainAppContent(
|
|||
onToggleLibrary = {
|
||||
selectedPosterForActions?.let { preview ->
|
||||
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktConnected) {
|
||||
if (!isTraktLibrarySource) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerItem = libraryItem
|
||||
pickerTitle = preview.name
|
||||
pickerTabs = LibraryRepository.traktListTabs()
|
||||
pickerTabs = LibraryRepository.libraryListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
|
|
@ -1677,7 +1764,7 @@ private fun MainAppContent(
|
|||
coroutineScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.traktListTabs()
|
||||
val tabs = LibraryRepository.libraryListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore
|
|||
import com.nuvio.app.features.streams.StreamLaunchStore
|
||||
import com.nuvio.app.features.streams.StreamsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
|
|||
ThemeSettingsRepository.clearLocalState()
|
||||
PosterCardStyleRepository.clearLocalState()
|
||||
TraktAuthRepository.clearLocalState()
|
||||
TraktSettingsRepository.clearLocalState()
|
||||
PlayerSettingsRepository.clearLocalState()
|
||||
CatalogRepository.clear()
|
||||
StreamsRepository.clear()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
|||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
|
|
@ -150,12 +152,14 @@ object ProfileSettingsSync {
|
|||
val signatureFlows = listOf(
|
||||
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
||||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||
PlayerSettingsRepository.uiState.map { "player" },
|
||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
|
||||
)
|
||||
|
|
@ -199,6 +203,7 @@ object ProfileSettingsSync {
|
|||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
||||
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
||||
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
||||
notificationsSettings = NotificationsSettingsPayload(
|
||||
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
|
||||
|
|
@ -230,6 +235,9 @@ object ProfileSettingsSync {
|
|||
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||
|
||||
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
|
||||
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
|
||||
TraktCommentsSettings.onProfileChanged()
|
||||
|
||||
|
|
@ -244,6 +252,7 @@ object ProfileSettingsSync {
|
|||
MdbListSettingsRepository.ensureLoaded()
|
||||
MetaScreenSettingsRepository.ensureLoaded()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktCommentsSettings.ensureLoaded()
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
|
|
@ -257,12 +266,14 @@ object ProfileSettingsSync {
|
|||
private fun currentObservedStateSignature(): String = listOf(
|
||||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||
"player=${PlayerSettingsRepository.uiState.value}",
|
||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
||||
).joinToString(separator = "||")
|
||||
|
|
@ -283,6 +294,7 @@ private data class MobileProfileSettingsFeatures(
|
|||
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
||||
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
|
||||
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
|
||||
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.core.ui
|
|||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
|
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
|
|||
@Composable
|
||||
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
|
||||
|
||||
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
|
||||
|
||||
@Composable
|
||||
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
|
||||
val navigationBarBottom = nuvioBottomNavigationBarInsets()
|
||||
.asPaddingValues()
|
||||
.calculateBottomPadding()
|
||||
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra
|
||||
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) +
|
||||
LocalNuvioBottomNavigationOverlayPadding.current +
|
||||
extra
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog
|
|||
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.toMetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -124,7 +127,7 @@ object CatalogRepository {
|
|||
catalogId = request.catalogId,
|
||||
genre = request.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
).withUnreleasedFilter()
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
if (activeRequest != request) return@fold
|
||||
|
|
@ -158,6 +161,12 @@ object CatalogRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private data class CatalogRequest(
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import com.nuvio.app.core.ui.posterCardClickable
|
|||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
|
@ -74,20 +75,21 @@ fun CatalogScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
force = false,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.debounce
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
|
|
@ -56,16 +57,13 @@ object CollectionSyncService {
|
|||
return
|
||||
}
|
||||
|
||||
val remoteJson = blob.collectionsJson.toString()
|
||||
val localJson = CollectionRepository.exportToJson()
|
||||
|
||||
if (remoteJson == "[]" || remoteJson == "null") {
|
||||
val currentCollections = CollectionRepository.collections.value
|
||||
if (currentCollections.isNotEmpty()) {
|
||||
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
|
||||
return
|
||||
}
|
||||
val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
|
||||
JsonArray(emptyList())
|
||||
} else {
|
||||
blob.collectionsJson
|
||||
}
|
||||
val remoteJson = remoteCollectionsJson.toString()
|
||||
val localJson = CollectionRepository.exportToJson()
|
||||
|
||||
if (remoteJson == localJson) {
|
||||
log.d { "pullFromServer — remote matches local, no update needed" }
|
||||
|
|
@ -78,7 +76,7 @@ object CollectionSyncService {
|
|||
|
||||
if (remoteCollections != null) {
|
||||
isSyncingFromRemote = true
|
||||
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
|
||||
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
|
||||
isSyncingFromRemote = false
|
||||
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ package com.nuvio.app.features.collection
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
import com.nuvio.app.features.catalog.CatalogPage
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.home.stableKey
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -320,7 +324,7 @@ object FolderDetailRepository {
|
|||
genre = currentTab.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
}.withUnreleasedFilter()
|
||||
}.onSuccess { page ->
|
||||
updateTab(index) { tab ->
|
||||
val mergedItems = if (reset) {
|
||||
|
|
@ -418,6 +422,12 @@ object FolderDetailRepository {
|
|||
|
||||
private fun Boolean?.orFalse(): Boolean = this == true
|
||||
|
||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private fun tmdbCatalogId(source: CollectionSource): String =
|
||||
buildString {
|
||||
append("tmdb_")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@ import com.nuvio.app.features.addons.AddonManifest
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -48,14 +51,14 @@ object MetaDetailsRepository {
|
|||
cachedEntry.metaScreenMeta
|
||||
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
|
||||
?.let { cachedMeta ->
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
return
|
||||
}
|
||||
|
||||
val cachedBaseMeta = cachedEntry.baseMeta
|
||||
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
return
|
||||
}
|
||||
|
|
@ -81,7 +84,7 @@ object MetaDetailsRepository {
|
|||
settingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
}
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
}
|
||||
return
|
||||
|
|
@ -302,7 +305,7 @@ object MetaDetailsRepository {
|
|||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
||||
|
||||
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
|
||||
_uiState.value = MetaDetailsUiState(meta = meta)
|
||||
_uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
return
|
||||
}
|
||||
|
|
@ -324,7 +327,7 @@ object MetaDetailsRepository {
|
|||
metaScreenMeta = enrichedMeta,
|
||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||
)
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
|
||||
activeRequestKey = requestKey
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +377,15 @@ object MetaDetailsRepository {
|
|||
return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
|
||||
}
|
||||
|
||||
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val todayIsoDate = CurrentDateProvider.todayIsoDate()
|
||||
return copy(
|
||||
moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
|
||||
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
|
||||
val meta = _uiState.value.meta ?: return emptyList()
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import com.nuvio.app.features.library.LibraryRepository
|
|||
import com.nuvio.app.features.library.toLibraryItem
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||
|
|
@ -165,6 +166,7 @@ fun MetaDetailsScreen(
|
|||
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
|
||||
|
||||
val shouldShowComments = commentsEnabled &&
|
||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||
|
|
@ -192,6 +194,30 @@ fun MetaDetailsScreen(
|
|||
isCommentsLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
|
||||
val metaForRatings = displayedMeta
|
||||
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id)
|
||||
val tmdbId = extractTmdbId(metaForRatings.id)
|
||||
?: extractTmdbId(id)
|
||||
?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull()
|
||||
?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull()
|
||||
|
||||
if (imdbId == null && tmdbId == null) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings(
|
||||
imdbId = imdbId,
|
||||
tmdbId = tmdbId,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
||||
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
||||
autoLoadAttempted = true
|
||||
|
|
@ -274,39 +300,39 @@ fun MetaDetailsScreen(
|
|||
val isSaved = remember(
|
||||
libraryUiState.items,
|
||||
libraryUiState.sections,
|
||||
traktAuthUiState.mode,
|
||||
libraryUiState.sourceMode,
|
||||
meta.id,
|
||||
meta.type,
|
||||
) {
|
||||
LibraryRepository.isSaved(meta.id, meta.type)
|
||||
}
|
||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||
val toggleSaved = remember(meta, isTraktConnected) {
|
||||
val openLibraryListPicker = remember(meta) {
|
||||
{
|
||||
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktConnected) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerTabs = LibraryRepository.traktListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
detailsScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.traktListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
pickerTabs = LibraryRepository.libraryListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
detailsScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.libraryListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
pickerPending = false
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
}
|
||||
Unit
|
||||
pickerPending = false
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
val toggleSaved = remember(meta) {
|
||||
{
|
||||
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
|
||||
}
|
||||
}
|
||||
val movieProgress = watchProgressUiState.byVideoId[meta.id]
|
||||
|
|
@ -637,6 +663,7 @@ fun MetaDetailsScreen(
|
|||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = toggleSaved,
|
||||
onSaveLongClick = openLibraryListPicker,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||
|
|
@ -653,6 +680,7 @@ fun MetaDetailsScreen(
|
|||
commentsCurrentPage = commentsCurrentPage,
|
||||
commentsPageCount = commentsPageCount,
|
||||
commentsError = commentsError,
|
||||
episodeImdbRatings = episodeImdbRatings,
|
||||
onRetryComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoading = true
|
||||
|
|
@ -687,6 +715,7 @@ fun MetaDetailsScreen(
|
|||
onTrailerClick = resolveTrailer,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodePlayClick,
|
||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||
onOpenMeta = onOpenMeta,
|
||||
|
|
@ -933,6 +962,30 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
||||
val normalizedType = type.trim().lowercase()
|
||||
val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null }
|
||||
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||
}
|
||||
|
||||
private fun extractImdbId(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.split(':', '/', '?', '&')
|
||||
?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
|
||||
?.takeIf { it.length > 2 }
|
||||
|
||||
private fun extractTmdbId(value: String?): Int? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isBlank()) return null
|
||||
return trimmed
|
||||
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
|
||||
?.substringAfter(':')
|
||||
?.substringBefore(':')
|
||||
?.substringBefore('/')
|
||||
?.toIntOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun ConfiguredMetaSections(
|
||||
|
|
@ -944,6 +997,7 @@ private fun ConfiguredMetaSections(
|
|||
onPrimaryPlayClick: () -> Unit,
|
||||
onPrimaryPlayLongClick: (() -> Unit)?,
|
||||
onSaveClick: () -> Unit,
|
||||
onSaveLongClick: (() -> Unit)?,
|
||||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
|
|
@ -960,12 +1014,14 @@ private fun ConfiguredMetaSections(
|
|||
commentsCurrentPage: Int,
|
||||
commentsPageCount: Int,
|
||||
commentsError: String?,
|
||||
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||
onRetryComments: () -> Unit,
|
||||
onLoadMoreComments: () -> Unit,
|
||||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
onTrailerClick: (MetaTrailer) -> Unit,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onEpisodeClick: (MetaVideo) -> Unit,
|
||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||
|
|
@ -1008,6 +1064,7 @@ private fun ConfiguredMetaSections(
|
|||
onPlayClick = onPrimaryPlayClick,
|
||||
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
|
||||
onSaveClick = onSaveClick,
|
||||
onSaveLongClick = onSaveLongClick,
|
||||
)
|
||||
}
|
||||
MetaScreenSectionKey.OVERVIEW -> {
|
||||
|
|
@ -1057,6 +1114,8 @@ private fun ConfiguredMetaSections(
|
|||
episodeCardStyle = settings.episodeCardStyle,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
episodeRatings = episodeImdbRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
|
|||
val cinematicBackground: Boolean = false,
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
val blurUnwatchedEpisodes: Boolean = false,
|
||||
)
|
||||
|
||||
enum class MetaEpisodeCardStyle {
|
||||
|
|
@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
|
|||
@SerialName("tvStyleLayout")
|
||||
val tabLayout: Boolean = false,
|
||||
val episodeCardStyle: String = "horizontal",
|
||||
@SerialName("blur_unwatched_episodes")
|
||||
val blurUnwatchedEpisodes: Boolean = false,
|
||||
)
|
||||
|
||||
private data class MetaScreenSectionDefinition(
|
||||
|
|
@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
|
|||
private var cinematicBackground: Boolean = false
|
||||
private var tabLayout: Boolean = false
|
||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
private var blurUnwatchedEpisodes: Boolean = false
|
||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||
|
||||
fun ensureLoaded() {
|
||||
|
|
@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
|
|||
tabLayout = parsed.tabLayout
|
||||
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
||||
?: MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
|
||||
preferences = parsed.items.mapNotNull { item ->
|
||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||
key to item
|
||||
|
|
@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
ensureLoaded()
|
||||
}
|
||||
|
|
@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
blurUnwatchedEpisodes = enabled
|
||||
publish()
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||
ensureLoaded()
|
||||
if (!key.canBeTabbed) return
|
||||
|
|
@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
|
|||
preferences.clear()
|
||||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
_uiState.value = MetaScreenSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground: Boolean,
|
||||
tabLayout: Boolean,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
) {
|
||||
ensureLoaded()
|
||||
this.cinematicBackground = cinematicBackground
|
||||
this.tabLayout = tabLayout
|
||||
this.episodeCardStyle = episodeCardStyle
|
||||
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
|
||||
preferences = items.associate { item ->
|
||||
item.key to StoredMetaScreenSectionPreference(
|
||||
key = item.key.name,
|
||||
|
|
@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = false
|
||||
tabLayout = false
|
||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||
blurUnwatchedEpisodes = false
|
||||
normalizePreferences()
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = episodeCardStyle,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
|
|||
cinematicBackground = cinematicBackground,
|
||||
tabLayout = tabLayout,
|
||||
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
|
|
@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
|||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||
)
|
||||
|
||||
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
||||
|
|
|
|||
|
|
@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -44,6 +41,7 @@ fun DetailActionButtons(
|
|||
onPlayClick: () -> Unit = {},
|
||||
onPlayLongClick: (() -> Unit)? = null,
|
||||
onSaveClick: () -> Unit = {},
|
||||
onSaveLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
|
||||
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
|
||||
|
|
@ -96,35 +94,49 @@ fun DetailActionButtons(
|
|||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onSaveClick,
|
||||
Surface(
|
||||
modifier = rowButtonModifier.height(50.dp),
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
if (isSaved) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = libraryAddPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onSaveClick,
|
||||
onLongClick = onSaveLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.height(50.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isSaved) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = libraryAddPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = saveLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = saveLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -45,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -76,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val log = Logger.withTag("SeriesContent")
|
||||
|
||||
|
|
@ -90,6 +96,8 @@ fun DetailSeriesContent(
|
|||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -276,6 +284,8 @@ fun DetailSeriesContent(
|
|||
watchedKeys = watchedKeys,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressByVideoId = progressByVideoId,
|
||||
episodeRatings = episodeRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
|
|
@ -295,13 +305,15 @@ fun DetailSeriesContent(
|
|||
video = episode,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = meta.type,
|
||||
metaId = meta.id,
|
||||
episode = episode,
|
||||
),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
sizing = sizing,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
|
|
@ -553,6 +565,8 @@ private fun EpisodeHorizontalRow(
|
|||
watchedKeys: Set<String>,
|
||||
fallbackImage: String?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||
|
|
@ -597,13 +611,15 @@ private fun EpisodeHorizontalRow(
|
|||
video = episode,
|
||||
fallbackImage = fallbackImage,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
||||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = metaType,
|
||||
metaId = parentMetaId,
|
||||
episode = episode,
|
||||
),
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
metrics = rowMetrics,
|
||||
onClick = { onEpisodeClick?.invoke(episode) },
|
||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||
|
|
@ -618,12 +634,17 @@ private fun EpisodeHorizontalCard(
|
|||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
imdbRating: Double?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
metrics: EpisodeHorizontalCardMetrics,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
||||
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(metrics.cardWidth)
|
||||
|
|
@ -642,11 +663,14 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -666,30 +690,6 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
|
||||
.clip(RoundedCornerShape(metrics.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.18f),
|
||||
shape = RoundedCornerShape(metrics.badgeRadius),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = metrics.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = Modifier
|
||||
|
|
@ -709,6 +709,15 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
EpisodeCodeBadge(
|
||||
text = video.episodeBadge(),
|
||||
textSize = metrics.badgeTextSize,
|
||||
radius = metrics.badgeRadius,
|
||||
horizontalPadding = metrics.badgeHorizontalPadding,
|
||||
verticalPadding = metrics.badgeVerticalPadding,
|
||||
backgroundAlpha = 0.42f,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = video.title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
|
|
@ -734,27 +743,39 @@ private fun EpisodeHorizontalCard(
|
|||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
||||
Text(
|
||||
text = formatEpisodeRuntime(runtimeMinutes),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
runtimeLabel?.let { runtime ->
|
||||
Text(
|
||||
text = runtime,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ratingLabel?.let { rating ->
|
||||
ImdbEpisodeRatingBadge(
|
||||
rating = rating,
|
||||
logoWidth = metrics.imdbLogoWidth,
|
||||
logoHeight = metrics.imdbLogoHeight,
|
||||
textSize = metrics.metaTextSize,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
formattedDate?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -793,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
|
|||
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeRadius: Dp,
|
||||
val badgeHorizontalPadding: Dp,
|
||||
val badgeVerticalPadding: Dp,
|
||||
val imdbLogoWidth: Dp,
|
||||
val imdbLogoHeight: Dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -815,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 11.sp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeRadius = 8.dp,
|
||||
badgeHorizontalPadding = 10.dp,
|
||||
badgeVerticalPadding = 5.dp,
|
||||
imdbLogoWidth = 28.dp,
|
||||
imdbLogoHeight = 14.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -834,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeRadius = 7.dp,
|
||||
badgeHorizontalPadding = 9.dp,
|
||||
badgeVerticalPadding = 4.dp,
|
||||
imdbLogoWidth = 26.dp,
|
||||
imdbLogoHeight = 13.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -853,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 2,
|
||||
metaTextSize = 11.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 5.dp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeHorizontalPadding = 8.dp,
|
||||
badgeVerticalPadding = 4.dp,
|
||||
imdbLogoWidth = 24.dp,
|
||||
imdbLogoHeight = 12.dp,
|
||||
)
|
||||
|
||||
else -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -873,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 9.sp,
|
||||
badgeRadius = 5.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
imdbLogoWidth = 22.dp,
|
||||
imdbLogoHeight = 11.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -882,19 +923,83 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
|
|||
return formatRuntimeFromMinutes(runtimeMinutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCodeBadge(
|
||||
text: String,
|
||||
textSize: androidx.compose.ui.unit.TextUnit,
|
||||
radius: Dp,
|
||||
horizontalPadding: Dp,
|
||||
verticalPadding: Dp,
|
||||
backgroundAlpha: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImdbEpisodeRatingBadge(
|
||||
rating: String,
|
||||
logoWidth: Dp,
|
||||
logoHeight: Dp,
|
||||
textSize: androidx.compose.ui.unit.TextUnit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.rating_imdb),
|
||||
contentDescription = stringResource(Res.string.source_imdb),
|
||||
modifier = Modifier
|
||||
.width(logoWidth)
|
||||
.height(logoHeight),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text(
|
||||
text = rating,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = Color(0xFFF5C518),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EpisodeListCard(
|
||||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
imdbRating: Double?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
sizing: SeriesContentSizing,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
||||
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -923,11 +1028,14 @@ private fun EpisodeListCard(
|
|||
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -938,32 +1046,17 @@ private fun EpisodeListCard(
|
|||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
EpisodeCodeBadge(
|
||||
text = video.episodeBadge(),
|
||||
textSize = sizing.badgeTextSize,
|
||||
radius = sizing.badgeRadius,
|
||||
horizontalPadding = sizing.badgeHorizontalPadding,
|
||||
verticalPadding = sizing.badgeVerticalPadding,
|
||||
backgroundAlpha = 0.85f,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 8.dp, top = 8.dp)
|
||||
.clip(RoundedCornerShape(sizing.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.85f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(sizing.badgeRadius),
|
||||
)
|
||||
.padding(
|
||||
horizontal = sizing.badgeHorizontalPadding,
|
||||
vertical = sizing.badgeVerticalPadding,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.3.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
.padding(start = 8.dp, top = 8.dp),
|
||||
)
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
|
|
@ -991,24 +1084,39 @@ private fun EpisodeListCard(
|
|||
fontSize = sizing.titleTextSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = sizing.titleLineHeight,
|
||||
letterSpacing = 0.3.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = sizing.titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (formattedDate != null || ratingLabel != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
formattedDate?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ratingLabel?.let { rating ->
|
||||
ImdbEpisodeRatingBadge(
|
||||
rating = rating,
|
||||
logoWidth = 24.dp,
|
||||
logoHeight = 12.dp,
|
||||
textSize = sizing.metaTextSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!video.overview.isNullOrBlank()) {
|
||||
|
|
@ -1211,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String =
|
|||
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
||||
}
|
||||
|
||||
private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {
|
||||
val seasonNumber = season ?: return null
|
||||
val episodeNumber = episode ?: return null
|
||||
return seasonNumber to episodeNumber
|
||||
}
|
||||
|
||||
private fun formatEpisodeRating(rating: Double): String {
|
||||
val roundedTenths = (rating * 10.0).roundToInt()
|
||||
val whole = roundedTenths / 10
|
||||
val tenth = (roundedTenths % 10).absoluteValue
|
||||
return "$whole.$tenth"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fun DownloadsScreen(
|
|||
val completedEpisodes = remember(uiState.items) {
|
||||
uiState.completedItems
|
||||
.filter { it.isEpisode }
|
||||
.sortedByDescending { it.updatedAtEpochMs }
|
||||
.sortedForSeriesDownloads()
|
||||
}
|
||||
|
||||
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
||||
|
|
@ -229,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
|
|||
) {
|
||||
val showEpisodes = episodes
|
||||
.filter { it.parentMetaId == showId }
|
||||
.sortedForSeriesDownloads()
|
||||
|
||||
val seasons = showEpisodes
|
||||
.groupBy { it.seasonNumber ?: 0 }
|
||||
|
|
@ -268,10 +269,7 @@ private fun LazyListScope.downloadsShowContent(
|
|||
)
|
||||
}
|
||||
|
||||
val sortedEpisodes = entries.sortedWith(
|
||||
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.updatedAtEpochMs },
|
||||
)
|
||||
val sortedEpisodes = entries.sortedForSeriesDownloads()
|
||||
|
||||
items(
|
||||
items = sortedEpisodes,
|
||||
|
|
@ -298,6 +296,12 @@ private fun DownloadRow(
|
|||
onRetry: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val displayTitle = item.displayTitle()
|
||||
val displaySubtitle = downloadDisplaySubtitle(
|
||||
item = item,
|
||||
displayTitle = displayTitle,
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -322,7 +326,7 @@ private fun DownloadRow(
|
|||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
text = displayTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -330,7 +334,7 @@ private fun DownloadRow(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = item.displaySubtitle,
|
||||
text = displaySubtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
|
@ -403,6 +407,36 @@ private fun DownloadRow(
|
|||
}
|
||||
}
|
||||
|
||||
private fun DownloadItem.displayTitle(): String =
|
||||
if (isEpisode) {
|
||||
episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: title
|
||||
} else {
|
||||
title
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun downloadDisplaySubtitle(
|
||||
item: DownloadItem,
|
||||
displayTitle: String,
|
||||
): String {
|
||||
val seasonNumber = item.seasonNumber
|
||||
val episodeNumber = item.episodeNumber
|
||||
if (seasonNumber == null || episodeNumber == null) {
|
||||
return item.displaySubtitle
|
||||
}
|
||||
|
||||
val episodeCode = stringResource(
|
||||
Res.string.compose_player_episode_code_full,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
)
|
||||
return listOf(
|
||||
episodeCode,
|
||||
item.episodeTitle?.trim().orEmpty().takeIf { it.isNotBlank() && it != displayTitle },
|
||||
item.title.trim().takeIf { it.isNotBlank() && it != displayTitle },
|
||||
).filterNotNull().joinToString(" • ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ internal object HomeCatalogParser {
|
|||
posterShape = meta.string("posterShape").toPosterShape(),
|
||||
description = meta.string("description"),
|
||||
releaseInfo = meta.string("releaseInfo"),
|
||||
rawReleaseDate = meta.string("released"),
|
||||
imdbRating = meta.string("imdbRating"),
|
||||
genres = meta.array("genres").mapNotNull { genre ->
|
||||
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
|
|
|
|||
|
|
@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem(
|
|||
|
||||
data class HomeCatalogSettingsUiState(
|
||||
val heroEnabled: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||
) {
|
||||
val signature: String
|
||||
get() = buildString {
|
||||
append(heroEnabled)
|
||||
append('|')
|
||||
append(hideUnreleasedContent)
|
||||
append('|')
|
||||
append(
|
||||
items.joinToString(separator = "|") { item ->
|
||||
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
||||
|
|
@ -55,6 +58,7 @@ internal data class HomeCatalogPreference(
|
|||
|
||||
internal data class HomeCatalogSettingsSnapshot(
|
||||
val heroEnabled: Boolean,
|
||||
val hideUnreleasedContent: Boolean,
|
||||
val preferences: Map<String, HomeCatalogPreference>,
|
||||
)
|
||||
|
||||
|
|
@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference(
|
|||
@Serializable
|
||||
private data class StoredHomeCatalogSettingsPayload(
|
||||
val heroEnabled: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository {
|
|||
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
|
||||
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
||||
private var heroEnabled = true
|
||||
private var hideUnreleasedContent = false
|
||||
|
||||
fun onProfileChanged() {
|
||||
hasLoaded = false
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
definitions = emptyList()
|
||||
collectionDefinitions = emptyList()
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
|
|
@ -105,6 +112,7 @@ object HomeCatalogSettingsRepository {
|
|||
collectionDefinitions = emptyList()
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +143,7 @@ object HomeCatalogSettingsRepository {
|
|||
ensureLoaded()
|
||||
return HomeCatalogSettingsSnapshot(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
preferences = preferences.mapValues { (_, value) ->
|
||||
HomeCatalogPreference(
|
||||
customTitle = value.customTitle,
|
||||
|
|
@ -154,6 +163,15 @@ object HomeCatalogSettingsRepository {
|
|||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
fun setHideUnreleasedContent(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (hideUnreleasedContent == enabled) return
|
||||
hideUnreleasedContent = enabled
|
||||
publish()
|
||||
persist()
|
||||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
||||
updatePreference(key) { preference ->
|
||||
if (!enabled) {
|
||||
|
|
@ -181,6 +199,7 @@ object HomeCatalogSettingsRepository {
|
|||
fun resetToDefaults() {
|
||||
ensureLoaded()
|
||||
heroEnabled = true
|
||||
hideUnreleasedContent = false
|
||||
preferences.clear()
|
||||
normalizePreferences()
|
||||
publish()
|
||||
|
|
@ -226,7 +245,9 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
if (parsedPayload != null) {
|
||||
heroEnabled = parsedPayload.heroEnabled
|
||||
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||
publish()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -235,6 +256,7 @@ object HomeCatalogSettingsRepository {
|
|||
}.getOrDefault(emptyList())
|
||||
|
||||
preferences = legacyItems.associateBy { it.key }.toMutableMap()
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun normalizePreferences() {
|
||||
|
|
@ -322,6 +344,7 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
_uiState.value = HomeCatalogSettingsUiState(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
|
@ -331,6 +354,7 @@ object HomeCatalogSettingsRepository {
|
|||
json.encodeToString(
|
||||
StoredHomeCatalogSettingsPayload(
|
||||
heroEnabled = heroEnabled,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
items = preferences.values.sortedBy { it.order },
|
||||
),
|
||||
),
|
||||
|
|
@ -411,26 +435,32 @@ object HomeCatalogSettingsRepository {
|
|||
)
|
||||
}
|
||||
}
|
||||
return SyncHomeCatalogPayload(items = items)
|
||||
return SyncHomeCatalogPayload(
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
items = items,
|
||||
)
|
||||
}
|
||||
|
||||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||
ensureLoaded()
|
||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||
preferences = payload.items.associate { item ->
|
||||
val key = if (item.isCollection) {
|
||||
"collection_${item.collectionId}"
|
||||
} else {
|
||||
"${item.addonId}:${item.type}:${item.catalogId}"
|
||||
}
|
||||
key to StoredHomeCatalogPreference(
|
||||
key = key,
|
||||
customTitle = item.customTitle,
|
||||
enabled = item.enabled,
|
||||
heroSourceEnabled = existingHeroState[key] ?: true,
|
||||
order = item.order,
|
||||
)
|
||||
}.toMutableMap()
|
||||
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||
if (payload.items.isNotEmpty()) {
|
||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||
preferences = payload.items.associate { item ->
|
||||
val key = if (item.isCollection) {
|
||||
"collection_${item.collectionId}"
|
||||
} else {
|
||||
"${item.addonId}:${item.type}:${item.catalogId}"
|
||||
}
|
||||
key to StoredHomeCatalogPreference(
|
||||
key = key,
|
||||
customTitle = item.customTitle,
|
||||
enabled = item.enabled,
|
||||
heroSourceEnabled = existingHeroState[key] ?: true,
|
||||
order = item.order,
|
||||
)
|
||||
}.toMutableMap()
|
||||
}
|
||||
hasLoaded = true
|
||||
publish()
|
||||
persist()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ data class SyncCatalogItem(
|
|||
|
||||
@Serializable
|
||||
data class SyncHomeCatalogPayload(
|
||||
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||
val items: List<SyncCatalogItem> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService {
|
|||
}
|
||||
|
||||
if (remotePayload.items.isEmpty()) {
|
||||
log.i { "pullFromServer — remote has empty items, preserving local" }
|
||||
log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
|
||||
isSyncingFromRemote = true
|
||||
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
|
||||
isSyncingFromRemote = false
|
||||
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||
if (localPayload.items.isNotEmpty()) {
|
||||
pushToRemote(profileId)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.home
|
|||
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -145,13 +146,17 @@ object HomeRepository {
|
|||
) {
|
||||
val snapshot = HomeCatalogSettingsRepository.snapshot()
|
||||
val preferences = snapshot.preferences
|
||||
val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null
|
||||
fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection =
|
||||
if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate)
|
||||
|
||||
val sections = currentDefinitions
|
||||
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
|
||||
.mapNotNull { definition ->
|
||||
val preference = preferences[definition.key]
|
||||
if (preference?.enabled == false) return@mapNotNull null
|
||||
|
||||
val section = cachedSections[definition.key] ?: return@mapNotNull null
|
||||
val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null
|
||||
if (section.items.isEmpty()) return@mapNotNull null
|
||||
val customTitle = preference?.customTitle.orEmpty()
|
||||
section.copy(
|
||||
|
|
@ -164,6 +169,7 @@ object HomeRepository {
|
|||
currentDefinitions
|
||||
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
|
||||
.mapNotNull { definition -> cachedSections[definition.key] }
|
||||
.map { section -> section.withReleaseFilter() }
|
||||
.flatMap { section -> section.items }
|
||||
.distinctBy { item -> "${item.type}:${item.id}" }
|
||||
.shuffled(heroRandom)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
||||
|
|
@ -29,6 +31,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection
|
|||
import com.nuvio.app.features.home.components.HomeSkeletonHero
|
||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.CachedInProgressItem
|
||||
import com.nuvio.app.features.watchprogress.CachedNextUpItem
|
||||
|
|
@ -36,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
|
|||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
|
|
@ -45,6 +52,7 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
|||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
||||
|
|
@ -87,6 +95,10 @@ fun HomeScreen(
|
|||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by remember {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val isTraktAuthenticated by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.isAuthenticated
|
||||
|
|
@ -114,17 +126,31 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
|
||||
if (!isTraktAuthenticated) {
|
||||
watchProgressUiState.entries
|
||||
} else {
|
||||
val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L)
|
||||
watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||
}
|
||||
val isTraktProgressActive = remember(
|
||||
isTraktAuthenticated,
|
||||
traktSettingsUiState.watchProgressSource,
|
||||
) {
|
||||
shouldUseTraktProgress(
|
||||
isAuthenticated = isTraktAuthenticated,
|
||||
source = traktSettingsUiState.watchProgressSource,
|
||||
)
|
||||
}
|
||||
|
||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) {
|
||||
if (isTraktAuthenticated) emptyList() else watchedUiState.items
|
||||
val effectiveWatchProgressEntries = remember(
|
||||
watchProgressUiState.entries,
|
||||
isTraktProgressActive,
|
||||
traktSettingsUiState.continueWatchingDaysCap,
|
||||
) {
|
||||
filterEntriesForTraktContinueWatchingWindow(
|
||||
entries = watchProgressUiState.entries,
|
||||
isTraktProgressActive = isTraktProgressActive,
|
||||
daysCap = traktSettingsUiState.continueWatchingDaysCap,
|
||||
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||
)
|
||||
}
|
||||
|
||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
|
||||
if (isTraktProgressActive) emptyList() else watchedUiState.items
|
||||
}
|
||||
|
||||
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
|
||||
|
|
@ -144,6 +170,9 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
val completedSeriesContentIds = remember(completedSeriesCandidates) {
|
||||
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
|
||||
}
|
||||
val visibleContinueWatchingEntries = remember(
|
||||
effectiveWatchProgressEntries,
|
||||
latestCompletedBySeries,
|
||||
|
|
@ -159,11 +188,28 @@ fun HomeScreen(
|
|||
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||
|
||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
val cachedNextUpItems = remember(
|
||||
cachedSnapshots.first,
|
||||
continueWatchingPreferences.dismissedNextUpKeys,
|
||||
completedSeriesContentIds,
|
||||
isTraktProgressActive,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
watchedUiState.isLoaded,
|
||||
) {
|
||||
cachedSnapshots.first.mapNotNull { cached ->
|
||||
if (
|
||||
!isTraktProgressActive &&
|
||||
watchedUiState.isLoaded &&
|
||||
cached.contentId !in completedSeriesContentIds
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
||||
cached.contentId to (cached.sortTimestamp to item)
|
||||
}.toMap()
|
||||
|
|
@ -242,7 +288,11 @@ fun HomeScreen(
|
|||
HomeCatalogSettingsRepository.syncCollections(collections)
|
||||
}
|
||||
|
||||
LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
|
||||
LaunchedEffect(
|
||||
completedSeriesCandidates,
|
||||
metaProviderKey,
|
||||
continueWatchingPreferences.showUnairedNextUp,
|
||||
) {
|
||||
if (completedSeriesCandidates.isEmpty()) {
|
||||
nextUpItemsBySeries = emptyMap()
|
||||
return@LaunchedEffect
|
||||
|
|
@ -263,7 +313,7 @@ fun HomeScreen(
|
|||
seasonNumber = completedEntry.seasonNumber,
|
||||
episodeNumber = completedEntry.episodeNumber,
|
||||
todayIsoDate = todayIsoDate,
|
||||
showUnairedNextUp = isTraktAuthenticated,
|
||||
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||
) ?: return@withPermit null
|
||||
val item = completedEntry.toContinueWatchingSeed(meta)
|
||||
.toUpNextContinueWatchingItem(nextEpisode)
|
||||
|
|
@ -291,6 +341,10 @@ fun HomeScreen(
|
|||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
pauseDescription = item.pauseDescription,
|
||||
released = item.released,
|
||||
hasAired = item.released?.let { released ->
|
||||
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
|
||||
} ?: true,
|
||||
lastWatched = pair.first,
|
||||
sortTimestamp = pair.first,
|
||||
seedSeason = item.nextUpSeedSeasonNumber,
|
||||
|
|
@ -353,12 +407,19 @@ fun HomeScreen(
|
|||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
|
||||
val nativeBottomNavigationOverlayHeight =
|
||||
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
|
||||
nuvioSafeBottomPadding()
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
val mobileHeroBelowSectionHeightHint = remember(
|
||||
maxWidth.value,
|
||||
continueWatchingPreferences.isVisible,
|
||||
continueWatchingPreferences.style,
|
||||
continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingLayout,
|
||||
nativeBottomNavigationOverlayHeight,
|
||||
) {
|
||||
heroMobileBelowSectionHeightHint(
|
||||
maxWidthDp = maxWidth.value,
|
||||
|
|
@ -366,6 +427,7 @@ fun HomeScreen(
|
|||
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingStyle = continueWatchingPreferences.style,
|
||||
continueWatchingLayout = continueWatchingLayout,
|
||||
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +471,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -432,6 +496,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -474,6 +540,8 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -525,7 +593,21 @@ fun HomeScreen(
|
|||
}
|
||||
|
||||
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
|
||||
private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
|
||||
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||
|
||||
internal fun filterEntriesForTraktContinueWatchingWindow(
|
||||
entries: List<WatchProgressEntry>,
|
||||
isTraktProgressActive: Boolean,
|
||||
daysCap: Int,
|
||||
nowEpochMs: Long,
|
||||
): List<WatchProgressEntry> {
|
||||
if (!isTraktProgressActive) return entries
|
||||
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
|
||||
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
|
||||
|
||||
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
|
||||
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||
}
|
||||
|
||||
private fun heroMobileBelowSectionHeightHint(
|
||||
maxWidthDp: Float,
|
||||
|
|
@ -533,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
|
|||
hasContinueWatchingItems: Boolean,
|
||||
continueWatchingStyle: ContinueWatchingSectionStyle,
|
||||
continueWatchingLayout: ContinueWatchingLayout,
|
||||
bottomNavigationOverlayHeight: Dp,
|
||||
): Dp? {
|
||||
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
|
||||
|
||||
return when (continueWatchingStyle) {
|
||||
val sectionHeight = when (continueWatchingStyle) {
|
||||
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
|
||||
ContinueWatchingSectionStyle.Poster ->
|
||||
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
|
||||
}
|
||||
return sectionHeight + bottomNavigationOverlayHeight
|
||||
}
|
||||
|
||||
internal fun buildHomeContinueWatchingItems(
|
||||
|
|
@ -548,6 +632,13 @@ internal fun buildHomeContinueWatchingItems(
|
|||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||
): List<ContinueWatchingItem> {
|
||||
val inProgressSeriesIds = visibleEntries
|
||||
.asSequence()
|
||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||
.map { entry -> entry.parentMetaId }
|
||||
.filter(String::isNotBlank)
|
||||
.toSet()
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
visibleEntries.map { entry ->
|
||||
|
|
@ -560,7 +651,8 @@ internal fun buildHomeContinueWatchingItems(
|
|||
},
|
||||
)
|
||||
addAll(
|
||||
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
|
||||
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
|
||||
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
|
||||
HomeContinueWatchingCandidate(
|
||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
item = item,
|
||||
|
|
@ -574,7 +666,7 @@ internal fun buildHomeContinueWatchingItems(
|
|||
.thenByDescending { it.isProgressEntry },
|
||||
)
|
||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||
.distinctBy { it.item.videoId }
|
||||
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
||||
.map(HomeContinueWatchingCandidate::item)
|
||||
}
|
||||
|
||||
|
|
@ -632,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
|||
episodeTitle = episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
pauseDescription = pauseDescription,
|
||||
released = released,
|
||||
isNextUp = true,
|
||||
nextUpSeedSeasonNumber = seedSeason,
|
||||
nextUpSeedEpisodeNumber = seedEpisode,
|
||||
|
|
@ -698,5 +791,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
|||
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
||||
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
||||
pauseDescription = pauseDescription ?: fallback.pauseDescription,
|
||||
released = released ?: fallback.released,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
|
|||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||
|
||||
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
||||
useEpisodeThumbnails: Boolean,
|
||||
): String? = when {
|
||||
isNextUp && useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
poster,
|
||||
background,
|
||||
imageUrl,
|
||||
)
|
||||
isNextUp -> firstNonBlank(
|
||||
poster,
|
||||
background,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
poster,
|
||||
background,
|
||||
imageUrl,
|
||||
)
|
||||
else -> firstNonBlank(
|
||||
poster,
|
||||
background,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String? =
|
||||
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
|
||||
|
||||
@Composable
|
||||
internal fun HomeContinueWatchingSection(
|
||||
items: List<ContinueWatchingItem>,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
useEpisodeThumbnails: Boolean = true,
|
||||
blurNextUp: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
sectionPadding: Dp? = null,
|
||||
layout: ContinueWatchingLayout? = null,
|
||||
|
|
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
|
|||
HomeContinueWatchingSectionContent(
|
||||
items = items,
|
||||
style = style,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
sectionPadding = sectionPadding,
|
||||
layout = layout,
|
||||
|
|
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
|
|||
HomeContinueWatchingSectionContent(
|
||||
items = items,
|
||||
style = style,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||
layout = rememberContinueWatchingLayout(maxWidth.value),
|
||||
|
|
@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection(
|
|||
private fun HomeContinueWatchingSectionContent(
|
||||
items: List<ContinueWatchingItem>,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
modifier: Modifier,
|
||||
sectionPadding: Dp,
|
||||
layout: ContinueWatchingLayout,
|
||||
|
|
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
|
|||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
|
|
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
|
|||
private fun ContinueWatchingWideCard(
|
||||
item: ContinueWatchingItem,
|
||||
layout: ContinueWatchingLayout,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
|
|
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
|
|||
onLongClick = onLongClick,
|
||||
),
|
||||
) {
|
||||
val artworkUrl = item.poster ?: item.background ?: item.imageUrl
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
ArtworkPanel(
|
||||
imageUrl = artworkUrl,
|
||||
width = layout.widePosterStripWidth,
|
||||
blurred = shouldBlurArtwork,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
Column(
|
||||
|
|
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
|
|||
private fun ContinueWatchingPosterCard(
|
||||
item: ContinueWatchingItem,
|
||||
layout: ContinueWatchingLayout,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
|
|
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
|
|||
)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
val imageUrl = item.poster ?: item.imageUrl
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -489,6 +543,7 @@ private fun ContinueWatchingPosterCard(
|
|||
private fun ArtworkPanel(
|
||||
imageUrl: String?,
|
||||
width: Dp,
|
||||
blurred: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -500,7 +555,9 @@ private fun ArtworkPanel(
|
|||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider
|
|||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktLibraryRepository
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktListType
|
||||
import com.nuvio.app.features.trakt.TraktMembershipChanges
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -65,12 +72,28 @@ object LibraryRepository {
|
|||
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
||||
if (authenticated) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
|
||||
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
|
||||
}
|
||||
}
|
||||
publish()
|
||||
}
|
||||
}
|
||||
syncScope.launch {
|
||||
TraktSettingsRepository.uiState
|
||||
.map { it.librarySourceMode }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { source ->
|
||||
if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
publish()
|
||||
refreshTraktLibraryAsync()
|
||||
} else {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
syncScope.launch {
|
||||
TraktLibraryRepository.uiState.collectLatest {
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
|
|
@ -82,23 +105,29 @@ object LibraryRepository {
|
|||
|
||||
fun ensureLoaded() {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktLibraryRepository.ensureLoaded()
|
||||
if (hasLoaded) return
|
||||
loadFromDisk(ProfileRepository.activeProfileId)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
refreshTraktLibraryAsync()
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
refreshTraktLibraryAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onProfileChanged(profileId: Int) {
|
||||
if (profileId == currentProfileId && hasLoaded) return
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
loadFromDisk(profileId)
|
||||
TraktAuthRepository.onProfileChanged()
|
||||
TraktLibraryRepository.onProfileChanged()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
refreshTraktLibraryAsync()
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
refreshTraktLibraryAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +159,7 @@ object LibraryRepository {
|
|||
suspend fun pullFromServer(profileId: Int) {
|
||||
currentProfileId = profileId
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
|
||||
publish()
|
||||
|
|
@ -157,7 +186,7 @@ object LibraryRepository {
|
|||
fun toggleSaved(item: LibraryItem) {
|
||||
ensureLoaded()
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
syncScope.launch {
|
||||
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
|
||||
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
|
||||
|
|
@ -175,7 +204,6 @@ object LibraryRepository {
|
|||
|
||||
fun save(item: LibraryItem) {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) return
|
||||
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -184,7 +212,6 @@ object LibraryRepository {
|
|||
|
||||
fun remove(id: String) {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) return
|
||||
if (itemsById.remove(id) != null) {
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -195,7 +222,7 @@ object LibraryRepository {
|
|||
fun isSaved(id: String, type: String? = null): Boolean {
|
||||
ensureLoaded()
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
if (type != null) {
|
||||
return TraktLibraryRepository.isInAnyList(id, type)
|
||||
}
|
||||
|
|
@ -212,46 +239,65 @@ object LibraryRepository {
|
|||
fun savedItem(id: String): LibraryItem? {
|
||||
ensureLoaded()
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
return itemsById[id]
|
||||
}
|
||||
|
||||
fun traktListTabs() = TraktLibraryRepository.currentListTabs()
|
||||
fun libraryListTabs(): List<TraktListTab> {
|
||||
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.currentListTabs()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return libraryTabsWithLocal(traktTabs)
|
||||
}
|
||||
|
||||
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
|
||||
|
||||
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||
}
|
||||
val inLocal = itemsById.containsKey(item.id)
|
||||
return mapOf(LOCAL_LIST_KEY to inLocal)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||
return libraryMembershipWithLocal(
|
||||
inLocal = inLocal,
|
||||
traktMembership = traktMembership,
|
||||
)
|
||||
}
|
||||
return libraryMembershipWithLocal(inLocal = inLocal)
|
||||
}
|
||||
|
||||
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.applyMembershipChanges(
|
||||
item = item,
|
||||
changes = TraktMembershipChanges(desiredMembership = desiredMembership),
|
||||
)
|
||||
publish()
|
||||
return
|
||||
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
|
||||
val currentlyInLocal = itemsById.containsKey(item.id)
|
||||
if (localDesired != currentlyInLocal) {
|
||||
if (localDesired) {
|
||||
save(item)
|
||||
} else {
|
||||
remove(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldBeSaved = desiredMembership.values.any { it }
|
||||
if (shouldBeSaved) {
|
||||
save(item)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
|
||||
if (traktMembership.isNotEmpty()) {
|
||||
TraktLibraryRepository.applyMembershipChanges(
|
||||
item = item,
|
||||
changes = TraktMembershipChanges(desiredMembership = traktMembership),
|
||||
)
|
||||
}
|
||||
publish()
|
||||
} else {
|
||||
remove(item.id)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushToServer() {
|
||||
syncScope.launch {
|
||||
if (TraktAuthRepository.isAuthenticated.value) return@launch
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val syncItems = itemsById.values.map { it.toSyncItem() }
|
||||
|
|
@ -267,7 +313,7 @@ object LibraryRepository {
|
|||
}
|
||||
|
||||
private fun publish() {
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
val traktState = TraktLibraryRepository.uiState.value
|
||||
val sections = traktState.listTabs.mapNotNull { tab ->
|
||||
val listItems = traktState.entriesByList[tab.key].orEmpty()
|
||||
|
|
@ -334,9 +380,42 @@ object LibraryRepository {
|
|||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedLibrarySourceMode(): LibrarySourceMode {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
return TraktSettingsRepository.uiState.value.librarySourceMode
|
||||
}
|
||||
|
||||
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
|
||||
resolveEffectiveLibrarySourceMode(
|
||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||
source = selectedLibrarySourceMode(),
|
||||
)
|
||||
|
||||
private fun isTraktLibrarySourceActive(): Boolean =
|
||||
effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
|
||||
}
|
||||
|
||||
private const val LOCAL_LIST_KEY = "local"
|
||||
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
|
||||
internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library"
|
||||
|
||||
internal fun localLibraryListTab(): TraktListTab =
|
||||
TraktListTab(
|
||||
key = LOCAL_LIBRARY_LIST_KEY,
|
||||
title = LOCAL_LIBRARY_LIST_TITLE,
|
||||
type = TraktListType.WATCHLIST,
|
||||
)
|
||||
|
||||
internal fun libraryTabsWithLocal(traktTabs: List<TraktListTab>): List<TraktListTab> =
|
||||
listOf(localLibraryListTab()) + traktTabs
|
||||
|
||||
internal fun libraryMembershipWithLocal(
|
||||
inLocal: Boolean,
|
||||
traktMembership: Map<String, Boolean> = emptyMap(),
|
||||
): Map<String, Boolean> =
|
||||
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
|
||||
putAll(traktMembership)
|
||||
}
|
||||
|
||||
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
||||
id = contentId,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ fun LibraryScreen(
|
|||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
||||
val retryLibraryLoad: () -> Unit = {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
coroutineScope.launch {
|
||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
|
||||
when (networkStatusUiState.condition) {
|
||||
|
|
@ -110,14 +116,7 @@ fun LibraryScreen(
|
|||
NuvioNetworkOfflineCard(
|
||||
condition = networkStatusUiState.condition,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onRetry = {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
if (isTraktSource) {
|
||||
coroutineScope.launch {
|
||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRetry = retryLibraryLoad,
|
||||
)
|
||||
} else {
|
||||
HomeEmptyStateCard(
|
||||
|
|
@ -128,6 +127,8 @@ fun LibraryScreen(
|
|||
stringResource(Res.string.library_load_failed)
|
||||
},
|
||||
message = uiState.errorMessage.orEmpty(),
|
||||
actionLabel = stringResource(Res.string.action_retry),
|
||||
onActionClick = retryLibraryLoad,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -139,12 +140,7 @@ fun LibraryScreen(
|
|||
NuvioNetworkOfflineCard(
|
||||
condition = networkStatusUiState.condition,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onRetry = {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
coroutineScope.launch {
|
||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
||||
}
|
||||
},
|
||||
onRetry = retryLibraryLoad,
|
||||
)
|
||||
} else {
|
||||
HomeEmptyStateCard(
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ internal fun PlayerControlsShell(
|
|||
episodeTitle = episodeTitle,
|
||||
metrics = metrics,
|
||||
isLocked = isLocked,
|
||||
onSubmitIntroClick = onSubmitIntroClick,
|
||||
onLockToggle = onLockToggle,
|
||||
onBack = onBack,
|
||||
modifier = Modifier
|
||||
|
|
@ -168,7 +169,6 @@ internal fun PlayerControlsShell(
|
|||
onAudioClick = onAudioClick,
|
||||
onSourcesClick = onSourcesClick,
|
||||
onEpisodesClick = onEpisodesClick,
|
||||
onSubmitIntroClick = onSubmitIntroClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
|
|
@ -189,6 +189,7 @@ private fun PlayerHeader(
|
|||
episodeTitle: String?,
|
||||
metrics: PlayerLayoutMetrics,
|
||||
isLocked: Boolean,
|
||||
onSubmitIntroClick: (() -> Unit)?,
|
||||
onLockToggle: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -264,6 +265,15 @@ private fun PlayerHeader(
|
|||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (onSubmitIntroClick != null) {
|
||||
PlayerHeaderIconButton(
|
||||
icon = Icons.Rounded.Flag,
|
||||
contentDescription = "Submit Intro",
|
||||
buttonSize = metrics.headerIconSize + 16.dp,
|
||||
iconSize = metrics.headerIconSize,
|
||||
onClick = onSubmitIntroClick,
|
||||
)
|
||||
}
|
||||
PlayerHeaderIconButton(
|
||||
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
||||
contentDescription = if (isLocked) {
|
||||
|
|
@ -424,7 +434,6 @@ private fun ProgressControls(
|
|||
onAudioClick: () -> Unit,
|
||||
onSourcesClick: (() -> Unit)? = null,
|
||||
onEpisodesClick: (() -> Unit)? = null,
|
||||
onSubmitIntroClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
||||
|
|
@ -506,13 +515,6 @@ private fun ProgressControls(
|
|||
onClick = onEpisodesClick,
|
||||
)
|
||||
}
|
||||
if (onSubmitIntroClick != null) {
|
||||
PlayerActionPillButton(
|
||||
label = "Submit Intro",
|
||||
icon = Icons.Rounded.Flag,
|
||||
onClick = onSubmitIntroClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -676,6 +678,9 @@ private fun PlayerActionPillButton(
|
|||
text = label,
|
||||
style = MaterialTheme.nuvioTypeScale.labelSm,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -60,6 +61,9 @@ import coil3.compose.AsyncImage
|
|||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
|
|
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
|
|||
fun PlayerEpisodesPanel(
|
||||
visible: Boolean,
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
// episode stream sub-view state
|
||||
episodeStreamsState: EpisodeStreamsPanelState,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
|
|
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
|
|||
} else {
|
||||
EpisodesListSubView(
|
||||
episodes = episodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = currentSeason,
|
||||
currentEpisode = currentEpisode,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onSeasonSelected = onSeasonSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
onDismiss = onDismiss,
|
||||
|
|
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
|
|||
@Composable
|
||||
private fun EpisodesListSubView(
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
|
|
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
|
|||
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
||||
) { _, episode ->
|
||||
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
|
||||
val episodeVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
fallbackVideoId = episode.id,
|
||||
)
|
||||
val isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
metaType = parentMetaType,
|
||||
metaId = parentMetaId,
|
||||
episode = episode,
|
||||
)
|
||||
EpisodeRow(
|
||||
episode = episode,
|
||||
isCurrent = isCurrent,
|
||||
isWatched = isWatched,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onClick = { onEpisodeSelected(episode) },
|
||||
)
|
||||
}
|
||||
|
|
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
|
|||
private fun EpisodeRow(
|
||||
episode: MetaVideo,
|
||||
isCurrent: Boolean,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -342,7 +379,8 @@ private fun EpisodeRow(
|
|||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.downloads.DownloadItem
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
|
|
@ -55,6 +56,7 @@ import com.nuvio.app.features.streams.StreamItem
|
|||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -143,6 +145,18 @@ fun PlayerScreen(
|
|||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val metaScreenSettingsUiState by remember {
|
||||
MetaScreenSettingsRepository.ensureLoaded()
|
||||
MetaScreenSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchedUiState by remember {
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by remember {
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
|
|
@ -1799,8 +1813,13 @@ fun PlayerScreen(
|
|||
PlayerEpisodesPanel(
|
||||
visible = showEpisodesPanel,
|
||||
episodes = allEpisodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = activeSeasonNumber,
|
||||
currentEpisode = activeEpisodeNumber,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
episodeStreamsState = episodeStreamsPanelState.copy(
|
||||
streamsUiState = episodeStreamsRepoState,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ fun ProfileEditScreen(
|
|||
|
||||
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
|
||||
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
|
||||
var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) }
|
||||
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
|
||||
var isSaving by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
|
@ -90,17 +91,20 @@ fun ProfileEditScreen(
|
|||
AvatarRepository.fetchAvatars()
|
||||
AvatarRepository.refreshAvatars()
|
||||
}
|
||||
LaunchedEffect(isNew, avatars, selectedAvatarId) {
|
||||
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) {
|
||||
LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) {
|
||||
if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) {
|
||||
selectedAvatarId = avatars.first().id
|
||||
}
|
||||
}
|
||||
|
||||
val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) }
|
||||
val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null
|
||||
val selectedAvatarItem = remember(selectedAvatarId, avatars) {
|
||||
selectedAvatarId?.let { id -> avatars.find { it.id == id } }
|
||||
}
|
||||
val previewAccent = remember(selectedAvatarItem, fallbackColorHex) {
|
||||
parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex)
|
||||
val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null
|
||||
val previewAccent = remember(visibleAvatarItem, fallbackColorHex) {
|
||||
parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex)
|
||||
}
|
||||
|
||||
NuvioScreen(modifier = modifier) {
|
||||
|
|
@ -123,12 +127,47 @@ fun ProfileEditScreen(
|
|||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
onNameChange = { name = it },
|
||||
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
|
||||
selectedAvatar = selectedAvatarItem,
|
||||
selectedAvatar = visibleAvatarItem,
|
||||
customAvatarUrl = customAvatarUrl,
|
||||
accentColor = previewAccent,
|
||||
hasAvatarChoices = avatars.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
NuvioSurfaceCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.profile_custom_avatar_url),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.profile_custom_avatar_url_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
NuvioInputField(
|
||||
value = avatarUrl,
|
||||
onValueChange = { value ->
|
||||
avatarUrl = value
|
||||
if (value.isNotBlank()) {
|
||||
selectedAvatarId = null
|
||||
}
|
||||
},
|
||||
placeholder = stringResource(Res.string.profile_custom_avatar_url_placeholder),
|
||||
)
|
||||
if (avatarUrlIsInvalid) {
|
||||
Text(
|
||||
text = stringResource(Res.string.profile_avatar_url_invalid),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NuvioSurfaceCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
|
|
@ -165,8 +204,11 @@ fun ProfileEditScreen(
|
|||
AvatarChoiceItem(
|
||||
avatar = avatar,
|
||||
size = avatarSize,
|
||||
isSelected = avatar.id == selectedAvatarId,
|
||||
onClick = { selectedAvatarId = avatar.id },
|
||||
isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId,
|
||||
onClick = {
|
||||
avatarUrl = ""
|
||||
selectedAvatarId = avatar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -220,16 +262,17 @@ fun ProfileEditScreen(
|
|||
} else {
|
||||
stringResource(Res.string.collections_editor_save_changes)
|
||||
},
|
||||
enabled = name.isNotBlank() && !isSaving,
|
||||
enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving,
|
||||
onClick = {
|
||||
isSaving = true
|
||||
scope.launch {
|
||||
val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex
|
||||
val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex
|
||||
if (isNew) {
|
||||
ProfileRepository.createProfile(
|
||||
name = name,
|
||||
avatarColorHex = avatarColorHex,
|
||||
avatarId = selectedAvatarId,
|
||||
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
|
||||
avatarUrl = customAvatarUrl,
|
||||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -237,7 +280,8 @@ fun ProfileEditScreen(
|
|||
profileIndex = currentProfile!!.profileIndex,
|
||||
name = name,
|
||||
avatarColorHex = avatarColorHex,
|
||||
avatarId = selectedAvatarId,
|
||||
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
|
||||
avatarUrl = customAvatarUrl,
|
||||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
)
|
||||
}
|
||||
|
|
@ -330,6 +374,7 @@ private fun ProfileIdentityCard(
|
|||
onNameChange: (String) -> Unit,
|
||||
onUsesPrimaryAddonsChange: (Boolean) -> Unit,
|
||||
selectedAvatar: AvatarCatalogItem?,
|
||||
customAvatarUrl: String?,
|
||||
accentColor: Color,
|
||||
hasAvatarChoices: Boolean,
|
||||
) {
|
||||
|
|
@ -345,16 +390,31 @@ private fun ProfileIdentityCard(
|
|||
.size(88.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f),
|
||||
if (selectedAvatar != null || customAvatarUrl != null) {
|
||||
accentColor
|
||||
} else {
|
||||
accentColor.copy(alpha = 0.18f)
|
||||
},
|
||||
)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (selectedAvatar == null) accentColor.copy(alpha = 0.35f) else Color.Transparent,
|
||||
color = if (selectedAvatar == null && customAvatarUrl == null) {
|
||||
accentColor.copy(alpha = 0.35f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selectedAvatar != null) {
|
||||
if (customAvatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = customAvatarUrl,
|
||||
contentDescription = name,
|
||||
modifier = Modifier.size(88.dp).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else if (selectedAvatar != null) {
|
||||
AsyncImage(
|
||||
model = avatarStorageUrl(selectedAvatar.storagePath),
|
||||
contentDescription = selectedAvatar.displayName,
|
||||
|
|
@ -410,6 +470,7 @@ private fun ProfileIdentityCard(
|
|||
)
|
||||
Text(
|
||||
text = when {
|
||||
customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected)
|
||||
selectedAvatar != null -> stringResource(
|
||||
Res.string.profile_avatar_selected,
|
||||
selectedAvatar.displayName,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ data class NuvioProfile(
|
|||
val name: String = "",
|
||||
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
|
||||
@SerialName("avatar_id") val avatarId: String? = null,
|
||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
|
||||
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
||||
@SerialName("pin_enabled") val pinEnabled: Boolean = false,
|
||||
|
|
@ -28,6 +29,7 @@ data class ProfilePushPayload(
|
|||
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
|
||||
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
||||
@SerialName("avatar_id") val avatarId: String? = null,
|
||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf(
|
|||
|
||||
fun avatarStorageUrl(storagePath: String): String =
|
||||
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
|
||||
|
||||
fun normalizedAvatarUrl(url: String?): String? =
|
||||
url?.trim()?.takeIf { it.isValidAvatarUrl() }
|
||||
|
||||
fun String.isValidAvatarUrl(): Boolean {
|
||||
val value = trim()
|
||||
return value.length <= 2048 &&
|
||||
!value.any { it.isWhitespace() } &&
|
||||
(value.startsWith("https://") || value.startsWith("http://"))
|
||||
}
|
||||
|
||||
fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): String? =
|
||||
normalizedAvatarUrl(profile.avatarUrl)
|
||||
?: avatar
|
||||
?.storagePath
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::avatarStorageUrl)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.nuvio.app.features.plugins.PluginRepository
|
|||
import com.nuvio.app.features.search.SearchHistoryRepository
|
||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
|
|
@ -135,6 +136,7 @@ object ProfileRepository {
|
|||
)
|
||||
persist()
|
||||
WatchedRepository.onProfileChanged(profileIndex)
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
LibraryRepository.onProfileChanged(profileIndex)
|
||||
WatchProgressRepository.onProfileChanged(profileIndex)
|
||||
AddonRepository.onProfileChanged(profileIndex)
|
||||
|
|
@ -177,6 +179,7 @@ object ProfileRepository {
|
|||
name: String,
|
||||
avatarColorHex: String,
|
||||
avatarId: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
usesPrimaryAddons: Boolean = false,
|
||||
) {
|
||||
val existing = _state.value.profiles
|
||||
|
|
@ -190,6 +193,7 @@ object ProfileRepository {
|
|||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||
avatarId = profile.avatarId,
|
||||
avatarUrl = profile.avatarUrl,
|
||||
)
|
||||
} + ProfilePushPayload(
|
||||
profileIndex = nextIndex,
|
||||
|
|
@ -197,6 +201,7 @@ object ProfileRepository {
|
|||
avatarColorHex = avatarColorHex,
|
||||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
avatarId = avatarId,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
||||
pushProfiles(allPayloads)
|
||||
|
|
@ -207,6 +212,7 @@ object ProfileRepository {
|
|||
name: String,
|
||||
avatarColorHex: String,
|
||||
avatarId: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
usesPrimaryAddons: Boolean = false,
|
||||
) {
|
||||
val allPayloads = _state.value.profiles.map { profile ->
|
||||
|
|
@ -216,7 +222,8 @@ object ProfileRepository {
|
|||
name = name,
|
||||
avatarColorHex = avatarColorHex,
|
||||
usesPrimaryAddons = usesPrimaryAddons,
|
||||
avatarId = avatarId ?: profile.avatarId,
|
||||
avatarId = avatarId,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
} else {
|
||||
ProfilePushPayload(
|
||||
|
|
@ -226,6 +233,7 @@ object ProfileRepository {
|
|||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||
avatarId = profile.avatarId,
|
||||
avatarUrl = profile.avatarUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -355,6 +363,7 @@ object ProfileRepository {
|
|||
name = p.name,
|
||||
avatarColorHex = p.avatarColorHex,
|
||||
avatarId = p.avatarId,
|
||||
avatarUrl = p.avatarUrl,
|
||||
usesPrimaryAddons = p.usesPrimaryAddons,
|
||||
usesPrimaryPlugins = p.usesPrimaryPlugins,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -304,6 +304,9 @@ private fun ProfileAvatarCard(
|
|||
val avatarItem = remember(profile.avatarId, avatars) {
|
||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||
}
|
||||
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||
profileAvatarImageUrl(profile, avatarItem)
|
||||
}
|
||||
|
||||
val animAlpha = remember { Animatable(0f) }
|
||||
val animScale = remember { Animatable(0.85f) }
|
||||
|
|
@ -342,8 +345,8 @@ private fun ProfileAvatarCard(
|
|||
modifier = Modifier.size(110.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (avatarItem != null) {
|
||||
val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||
if (avatarImageUrl != null) {
|
||||
val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(110.dp)
|
||||
|
|
@ -364,15 +367,15 @@ private fun ProfileAvatarCard(
|
|||
},
|
||||
)
|
||||
.then(
|
||||
if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
|
||||
if (avatarImageUrl == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
|
||||
else Modifier,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (avatarItem != null) {
|
||||
if (avatarImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = avatarStorageUrl(avatarItem.storagePath),
|
||||
contentDescription = avatarItem.displayName,
|
||||
model = avatarImageUrl,
|
||||
contentDescription = avatarItem?.displayName ?: profile.name,
|
||||
modifier = Modifier.size(100.dp).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -341,6 +341,9 @@ private fun PopupProfileBubble(
|
|||
val avatarItem = remember(profile.avatarId, avatars) {
|
||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||
}
|
||||
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||
profileAvatarImageUrl(profile, avatarItem)
|
||||
}
|
||||
|
||||
// Per-item entrance animation
|
||||
val itemAlpha = remember { Animatable(0f) }
|
||||
|
|
@ -393,8 +396,8 @@ private fun PopupProfileBubble(
|
|||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (avatarItem != null) {
|
||||
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||
if (avatarImageUrl != null) {
|
||||
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||
} else {
|
||||
avatarColor.copy(alpha = 0.15f)
|
||||
},
|
||||
|
|
@ -411,7 +414,7 @@ private fun PopupProfileBubble(
|
|||
avatarColor.copy(alpha = 0.6f),
|
||||
CircleShape,
|
||||
)
|
||||
avatarItem == null -> Modifier.border(
|
||||
avatarImageUrl == null -> Modifier.border(
|
||||
1.5.dp,
|
||||
avatarColor.copy(alpha = 0.3f),
|
||||
CircleShape,
|
||||
|
|
@ -421,9 +424,9 @@ private fun PopupProfileBubble(
|
|||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (avatarItem != null) {
|
||||
if (avatarImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = avatarStorageUrl(avatarItem.storagePath),
|
||||
model = avatarImageUrl,
|
||||
contentDescription = profile.name,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar(
|
|||
val avatarItem = remember(profile.avatarId, avatars) {
|
||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||
}
|
||||
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||
profileAvatarImageUrl(profile, avatarItem)
|
||||
}
|
||||
|
||||
val borderColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
|
|
@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar(
|
|||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (avatarItem != null) {
|
||||
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||
if (avatarImageUrl != null) {
|
||||
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||
} else {
|
||||
avatarColor.copy(alpha = 0.15f)
|
||||
},
|
||||
|
|
@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar(
|
|||
.border(1.5.dp, borderColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (avatarItem != null) {
|
||||
if (avatarImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = avatarStorageUrl(avatarItem.storagePath),
|
||||
model = avatarImageUrl,
|
||||
contentDescription = profile.name,
|
||||
modifier = Modifier.size(size.dp).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
|||
import com.nuvio.app.features.addons.AddonCatalog
|
||||
import com.nuvio.app.features.addons.AddonExtraProperty
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.catalog.CatalogPage
|
||||
import com.nuvio.app.features.catalog.buildCatalogUrl
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -37,6 +41,7 @@ object SearchRepository {
|
|||
private var activeDiscoverJob: Job? = null
|
||||
private var lastRequestKey: String? = null
|
||||
private var discoverSources: List<DiscoverCatalogOption> = emptyList()
|
||||
private var lastDiscoverHideUnreleasedContent: Boolean? = null
|
||||
|
||||
fun search(query: String, addons: List<ManagedAddon>) {
|
||||
val normalizedQuery = query.trim()
|
||||
|
|
@ -71,6 +76,8 @@ object SearchRepository {
|
|||
val requestKey = buildString {
|
||||
append(normalizedQuery.lowercase())
|
||||
append('|')
|
||||
append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent)
|
||||
append('|')
|
||||
append(
|
||||
requests.joinToString(separator = "|") { request ->
|
||||
"${request.addon.manifestUrl}:${request.type}:${request.catalogId}"
|
||||
|
|
@ -119,6 +126,7 @@ object SearchRepository {
|
|||
activeDiscoverJob?.cancel()
|
||||
lastRequestKey = null
|
||||
discoverSources = emptyList()
|
||||
lastDiscoverHideUnreleasedContent = null
|
||||
_uiState.value = SearchUiState()
|
||||
_discoverUiState.value = DiscoverUiState()
|
||||
}
|
||||
|
|
@ -128,6 +136,7 @@ object SearchRepository {
|
|||
if (activeAddons.isEmpty()) {
|
||||
activeDiscoverJob?.cancel()
|
||||
discoverSources = emptyList()
|
||||
lastDiscoverHideUnreleasedContent = null
|
||||
log.d { "Discover refresh aborted: no active addons" }
|
||||
_discoverUiState.value = DiscoverUiState(
|
||||
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
|
||||
|
|
@ -137,7 +146,12 @@ object SearchRepository {
|
|||
|
||||
val sources = buildDiscoverSources(activeAddons)
|
||||
val current = _discoverUiState.value
|
||||
if (sources == discoverSources && current.canReuseDiscoverState(sources)) {
|
||||
val hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent
|
||||
if (
|
||||
sources == discoverSources &&
|
||||
lastDiscoverHideUnreleasedContent == hideUnreleasedContent &&
|
||||
current.canReuseDiscoverState(sources)
|
||||
) {
|
||||
log.d {
|
||||
"Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " +
|
||||
"genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}"
|
||||
|
|
@ -146,6 +160,7 @@ object SearchRepository {
|
|||
}
|
||||
|
||||
discoverSources = sources
|
||||
lastDiscoverHideUnreleasedContent = hideUnreleasedContent
|
||||
if (sources.isEmpty()) {
|
||||
activeDiscoverJob?.cancel()
|
||||
log.d { "Discover refresh found no compatible discover catalogs" }
|
||||
|
|
@ -310,7 +325,7 @@ object SearchRepository {
|
|||
type = type,
|
||||
catalogId = catalogId,
|
||||
search = query,
|
||||
)
|
||||
).withUnreleasedFilter()
|
||||
val items = page.items
|
||||
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
|
||||
|
||||
|
|
@ -364,7 +379,7 @@ object SearchRepository {
|
|||
catalogId = selectedCatalog.catalogId,
|
||||
genre = current.selectedGenre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
).withUnreleasedFilter()
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
val latest = _discoverUiState.value
|
||||
|
|
@ -421,6 +436,12 @@ object SearchRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private data class SearchCatalogRequest(
|
||||
val addon: ManagedAddon,
|
||||
val catalogId: String,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
|||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
|
|
@ -88,6 +89,7 @@ fun SearchScreen(
|
|||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
|
||||
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
|
||||
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -123,11 +125,11 @@ fun SearchScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(addonRefreshKey) {
|
||||
LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
SearchRepository.refreshDiscover(addonsUiState.addons)
|
||||
}
|
||||
|
||||
LaunchedEffect(query, addonRefreshKey) {
|
||||
LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
val normalizedQuery = query.trim()
|
||||
if (normalizedQuery.isBlank()) {
|
||||
lastRequestedQuery = null
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she
|
|||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_section_display
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_section_home
|
||||
|
|
@ -70,6 +72,9 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
liquidGlassNativeTabBarSupported: Boolean,
|
||||
liquidGlassNativeTabBarEnabled: Boolean,
|
||||
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
|
||||
selectedAppLanguage: AppLanguage,
|
||||
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||
onContinueWatchingClick: () -> Unit,
|
||||
|
|
@ -118,6 +123,16 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = onAmoledToggle,
|
||||
)
|
||||
if (liquidGlassNativeTabBarSupported) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_appearance_liquid_glass),
|
||||
description = stringResource(Res.string.settings_appearance_liquid_glass_description),
|
||||
checked = liquidGlassNativeTabBarEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = onLiquidGlassNativeTabBarToggle,
|
||||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_appearance_app_language),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
|||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
||||
|
|
@ -40,6 +44,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_style_wid
|
|||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_title
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
|
|
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isVisible: Boolean,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
upNextFromFurthestEpisode: Boolean,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
showUnairedNextUp: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
showResumePromptOnLaunch: Boolean,
|
||||
) {
|
||||
item {
|
||||
|
|
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description),
|
||||
checked = useEpisodeThumbnails,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUseEpisodeThumbnails,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
||||
|
|
@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description),
|
||||
checked = showUnairedNextUp,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setShowUnairedNextUp,
|
||||
)
|
||||
if (useEpisodeThumbnails) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_continue_watching_blur_next_up_title),
|
||||
description = stringResource(Res.string.settings_continue_watching_blur_next_up_description),
|
||||
checked = blurNextUp,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
|||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_reset
|
||||
import nuvio.composeapp.generated.resources.layout_hide_unreleased
|
||||
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
|
||||
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
||||
|
|
@ -62,6 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
|||
internal fun LazyListScope.homescreenSettingsContent(
|
||||
isTablet: Boolean,
|
||||
heroEnabled: Boolean,
|
||||
hideUnreleasedContent: Boolean,
|
||||
items: List<HomeCatalogSettingsItem>,
|
||||
) {
|
||||
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
||||
|
|
@ -87,6 +90,14 @@ internal fun LazyListScope.homescreenSettingsContent(
|
|||
isTablet = isTablet,
|
||||
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.layout_hide_unreleased),
|
||||
description = stringResource(Res.string.layout_hide_unreleased_sub),
|
||||
checked = hideUnreleasedContent,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ import nuvio.composeapp.generated.resources.settings_meta_episode_style_list
|
|||
import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description
|
||||
import nuvio.composeapp.generated.resources.settings_meta_episodes
|
||||
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
|
||||
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes
|
||||
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description
|
||||
import nuvio.composeapp.generated.resources.settings_meta_group_label
|
||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
|
||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
||||
|
|
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
selectedStyle = uiState.episodeCardStyle,
|
||||
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_meta_blur_unwatched_episodes),
|
||||
description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description),
|
||||
checked = uiState.blurUnwatchedEpisodes,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ fun HomescreenSettingsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homescreenSettingsUiState by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -74,6 +77,7 @@ fun HomescreenSettingsScreen(
|
|||
homescreenSettingsContent(
|
||||
isTablet = false,
|
||||
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||
items = homescreenSettingsUiState.items,
|
||||
)
|
||||
}
|
||||
|
|
@ -127,6 +131,9 @@ fun ContinueWatchingSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.max
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
||||
|
|
@ -56,6 +58,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
|
|||
import com.nuvio.app.features.trakt.TraktAuthUiState
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsUiState
|
||||
import com.nuvio.app.features.tmdb.TmdbSettings
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
|
|
@ -92,6 +96,10 @@ fun SettingsScreen(
|
|||
ThemeSettingsRepository.selectedTheme
|
||||
}.collectAsStateWithLifecycle()
|
||||
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarEnabled by remember {
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||
val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
|
||||
val tmdbSettings by remember {
|
||||
TmdbSettingsRepository.ensureLoaded()
|
||||
|
|
@ -109,6 +117,10 @@ fun SettingsScreen(
|
|||
TraktCommentsSettings.ensureLoaded()
|
||||
TraktCommentsSettings.enabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by remember {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val addonsUiState by remember {
|
||||
AddonRepository.initialize()
|
||||
AddonRepository.uiState
|
||||
|
|
@ -129,6 +141,7 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
val homescreenSettingsUiState by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val metaScreenSettingsUiState by remember {
|
||||
|
|
@ -184,6 +197,9 @@ fun SettingsScreen(
|
|||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
|
||||
selectedAppLanguage = selectedAppLanguage,
|
||||
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
|
||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||
|
|
@ -191,7 +207,9 @@ fun SettingsScreen(
|
|||
mdbListSettings = mdbListSettings,
|
||||
traktAuthUiState = traktAuthUiState,
|
||||
traktCommentsEnabled = traktCommentsEnabled,
|
||||
traktSettingsUiState = traktSettingsUiState,
|
||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||
homescreenItems = homescreenSettingsUiState.items,
|
||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
|
|
@ -224,6 +242,9 @@ fun SettingsScreen(
|
|||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
|
||||
selectedAppLanguage = selectedAppLanguage,
|
||||
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
|
||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||
|
|
@ -231,7 +252,9 @@ fun SettingsScreen(
|
|||
mdbListSettings = mdbListSettings,
|
||||
traktAuthUiState = traktAuthUiState,
|
||||
traktCommentsEnabled = traktCommentsEnabled,
|
||||
traktSettingsUiState = traktSettingsUiState,
|
||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||
homescreenItems = homescreenSettingsUiState.items,
|
||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||
|
|
@ -274,6 +297,9 @@ private fun MobileSettingsScreen(
|
|||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
liquidGlassNativeTabBarSupported: Boolean,
|
||||
liquidGlassNativeTabBarEnabled: Boolean,
|
||||
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
|
||||
selectedAppLanguage: AppLanguage,
|
||||
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||
|
|
@ -281,7 +307,9 @@ private fun MobileSettingsScreen(
|
|||
mdbListSettings: MdbListSettings,
|
||||
traktAuthUiState: TraktAuthUiState,
|
||||
traktCommentsEnabled: Boolean,
|
||||
traktSettingsUiState: TraktSettingsUiState,
|
||||
homescreenHeroEnabled: Boolean,
|
||||
homescreenHideUnreleasedContent: Boolean,
|
||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
|
|
@ -353,6 +381,9 @@ private fun MobileSettingsScreen(
|
|||
onThemeSelected = onThemeSelected,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = onAmoledToggle,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
|
||||
selectedAppLanguage = selectedAppLanguage,
|
||||
onAppLanguageSelected = onAppLanguageSelected,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
|
|
@ -367,6 +398,9 @@ private fun MobileSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||
|
|
@ -387,6 +421,7 @@ private fun MobileSettingsScreen(
|
|||
SettingsPage.Homescreen -> homescreenSettingsContent(
|
||||
isTablet = false,
|
||||
heroEnabled = homescreenHeroEnabled,
|
||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||
items = homescreenItems,
|
||||
)
|
||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||
|
|
@ -409,6 +444,7 @@ private fun MobileSettingsScreen(
|
|||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||
isTablet = false,
|
||||
uiState = traktAuthUiState,
|
||||
settingsUiState = traktSettingsUiState,
|
||||
commentsEnabled = traktCommentsEnabled,
|
||||
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
||||
)
|
||||
|
|
@ -439,6 +475,9 @@ private fun TabletSettingsScreen(
|
|||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
liquidGlassNativeTabBarSupported: Boolean,
|
||||
liquidGlassNativeTabBarEnabled: Boolean,
|
||||
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
|
||||
selectedAppLanguage: AppLanguage,
|
||||
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||
|
|
@ -446,7 +485,9 @@ private fun TabletSettingsScreen(
|
|||
mdbListSettings: MdbListSettings,
|
||||
traktAuthUiState: TraktAuthUiState,
|
||||
traktCommentsEnabled: Boolean,
|
||||
traktSettingsUiState: TraktSettingsUiState,
|
||||
homescreenHeroEnabled: Boolean,
|
||||
homescreenHideUnreleasedContent: Boolean,
|
||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||
|
|
@ -519,6 +560,7 @@ private fun TabletSettingsScreen(
|
|||
|
||||
saveableStateHolder.SaveableStateProvider(page.name) {
|
||||
val listState = rememberLazyListState()
|
||||
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -526,7 +568,7 @@ private fun TabletSettingsScreen(
|
|||
start = 40.dp,
|
||||
top = topOffset,
|
||||
end = 40.dp,
|
||||
bottom = 40.dp,
|
||||
bottom = 40.dp + bottomOverlayPadding,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
|
|
@ -589,6 +631,9 @@ private fun TabletSettingsScreen(
|
|||
onThemeSelected = onThemeSelected,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = onAmoledToggle,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
|
||||
selectedAppLanguage = selectedAppLanguage,
|
||||
onAppLanguageSelected = onAppLanguageSelected,
|
||||
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
|
||||
|
|
@ -603,6 +648,9 @@ private fun TabletSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||
|
|
@ -623,6 +671,7 @@ private fun TabletSettingsScreen(
|
|||
SettingsPage.Homescreen -> homescreenSettingsContent(
|
||||
isTablet = true,
|
||||
heroEnabled = homescreenHeroEnabled,
|
||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||
items = homescreenItems,
|
||||
)
|
||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||
|
|
@ -645,6 +694,7 @@ private fun TabletSettingsScreen(
|
|||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||
isTablet = true,
|
||||
uiState = traktAuthUiState,
|
||||
settingsUiState = traktSettingsUiState,
|
||||
commentsEnabled = traktCommentsEnabled,
|
||||
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import com.nuvio.app.core.ui.NativeTabBridge
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -12,6 +13,9 @@ object ThemeSettingsRepository {
|
|||
private val _amoledEnabled = MutableStateFlow(false)
|
||||
val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow()
|
||||
|
||||
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
|
||||
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
|
||||
|
||||
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
|
||||
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
|
||||
|
||||
|
|
@ -30,6 +34,9 @@ object ThemeSettingsRepository {
|
|||
hasLoaded = false
|
||||
_selectedTheme.value = AppTheme.WHITE
|
||||
_amoledEnabled.value = false
|
||||
_liquidGlassNativeTabBarEnabled.value = false
|
||||
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
|
||||
NativeTabBridge.publishLiquidGlassEnabled(false)
|
||||
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +53,11 @@ object ThemeSettingsRepository {
|
|||
AppTheme.WHITE
|
||||
}
|
||||
_selectedTheme.value = theme
|
||||
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
|
||||
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
|
||||
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
|
||||
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
|
||||
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
|
||||
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
|
||||
_selectedAppLanguage.value = appLanguage
|
||||
|
|
@ -57,6 +68,7 @@ object ThemeSettingsRepository {
|
|||
if (_selectedTheme.value == theme) return
|
||||
_selectedTheme.value = theme
|
||||
ThemeSettingsStorage.saveSelectedTheme(theme.name)
|
||||
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||
}
|
||||
|
||||
fun setAmoled(enabled: Boolean) {
|
||||
|
|
@ -66,6 +78,14 @@ object ThemeSettingsRepository {
|
|||
ThemeSettingsStorage.saveAmoledEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setLiquidGlassNativeTabBar(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (_liquidGlassNativeTabBarEnabled.value == enabled) return
|
||||
_liquidGlassNativeTabBarEnabled.value = enabled
|
||||
ThemeSettingsStorage.saveLiquidGlassNativeTabBarEnabled(enabled)
|
||||
NativeTabBridge.publishLiquidGlassEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setAppLanguage(language: AppLanguage) {
|
||||
ensureLoaded()
|
||||
if (_selectedAppLanguage.value == language) return
|
||||
|
|
@ -74,3 +94,13 @@ object ThemeSettingsRepository {
|
|||
_selectedAppLanguage.value = language
|
||||
}
|
||||
}
|
||||
|
||||
private fun AppTheme.nativeTabAccentHex(): String = when (this) {
|
||||
AppTheme.CRIMSON -> "#E53935"
|
||||
AppTheme.OCEAN -> "#1E88E5"
|
||||
AppTheme.VIOLET -> "#8E24AA"
|
||||
AppTheme.EMERALD -> "#43A047"
|
||||
AppTheme.AMBER -> "#FB8C00"
|
||||
AppTheme.ROSE -> "#D81B60"
|
||||
AppTheme.WHITE -> "#F5F5F5"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ internal expect object ThemeSettingsStorage {
|
|||
fun saveSelectedTheme(themeName: String)
|
||||
fun loadAmoledEnabled(): Boolean?
|
||||
fun saveAmoledEnabled(enabled: Boolean)
|
||||
fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
|
||||
fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
|
||||
fun loadSelectedAppLanguage(): String?
|
||||
fun saveSelectedAppLanguage(languageCode: String)
|
||||
fun applySelectedAppLanguage(languageCode: String)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,57 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.library.LibrarySourceMode
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktBrandAsset
|
||||
import com.nuvio.app.features.trakt.TraktAuthUiState
|
||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||
import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsUiState
|
||||
import com.nuvio.app.features.trakt.WatchProgressSource
|
||||
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
|
||||
import com.nuvio.app.features.trakt.traktBrandPainter
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.settings_playback_dialog_close
|
||||
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
|
||||
import nuvio.composeapp.generated.resources.settings_trakt_authentication
|
||||
import nuvio.composeapp.generated.resources.settings_trakt_comments
|
||||
|
|
@ -42,11 +68,34 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
|
|||
import nuvio.composeapp.generated.resources.settings_trakt_open_login
|
||||
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
|
||||
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
|
||||
import nuvio.composeapp.generated.resources.trakt_all_history
|
||||
import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_continue_watching_window
|
||||
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_cw_window_title
|
||||
import nuvio.composeapp.generated.resources.trakt_days_format
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_title
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_trakt
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_title
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
internal fun LazyListScope.traktSettingsContent(
|
||||
isTablet: Boolean,
|
||||
uiState: TraktAuthUiState,
|
||||
settingsUiState: TraktSettingsUiState,
|
||||
commentsEnabled: Boolean,
|
||||
onCommentsEnabledChange: (Boolean) -> Unit,
|
||||
) {
|
||||
|
|
@ -77,12 +126,414 @@ internal fun LazyListScope.traktSettingsContent(
|
|||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_trakt_comments),
|
||||
description = stringResource(Res.string.settings_trakt_comments_description),
|
||||
checked = commentsEnabled,
|
||||
TraktFeatureRows(
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = onCommentsEnabledChange,
|
||||
settingsUiState = settingsUiState,
|
||||
commentsEnabled = commentsEnabled,
|
||||
onCommentsEnabledChange = onCommentsEnabledChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraktFeatureRows(
|
||||
isTablet: Boolean,
|
||||
settingsUiState: TraktSettingsUiState,
|
||||
commentsEnabled: Boolean,
|
||||
onCommentsEnabledChange: (Boolean) -> Unit,
|
||||
) {
|
||||
var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var statusMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
|
||||
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
|
||||
val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
|
||||
val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
|
||||
val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
|
||||
val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected)
|
||||
val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected)
|
||||
|
||||
TraktSettingsActionRow(
|
||||
title = stringResource(Res.string.trakt_library_source_title),
|
||||
description = stringResource(Res.string.trakt_library_source_subtitle),
|
||||
value = librarySourceValue,
|
||||
isTablet = isTablet,
|
||||
onClick = { showLibrarySourceDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
TraktSettingsActionRow(
|
||||
title = stringResource(Res.string.trakt_watch_progress_title),
|
||||
description = stringResource(Res.string.trakt_watch_progress_subtitle),
|
||||
value = watchProgressValue,
|
||||
isTablet = isTablet,
|
||||
onClick = { showWatchProgressDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
TraktSettingsActionRow(
|
||||
title = stringResource(Res.string.trakt_continue_watching_window),
|
||||
description = stringResource(Res.string.trakt_continue_watching_subtitle),
|
||||
value = continueWatchingWindowValue,
|
||||
isTablet = isTablet,
|
||||
onClick = { showContinueWatchingWindowDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_trakt_comments),
|
||||
description = stringResource(Res.string.settings_trakt_comments_description),
|
||||
checked = commentsEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = onCommentsEnabledChange,
|
||||
)
|
||||
statusMessage?.takeIf { it.isNotBlank() }?.let { message ->
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
TraktInfoRow(
|
||||
isTablet = isTablet,
|
||||
text = message,
|
||||
)
|
||||
}
|
||||
|
||||
if (showLibrarySourceDialog) {
|
||||
LibrarySourceModeDialog(
|
||||
selectedSource = settingsUiState.librarySourceMode,
|
||||
onSourceSelected = { source ->
|
||||
TraktSettingsRepository.setLibrarySourceMode(source)
|
||||
statusMessage = if (source == LibrarySourceMode.TRAKT) {
|
||||
traktLibrarySelectedMessage
|
||||
} else {
|
||||
nuvioLibrarySelectedMessage
|
||||
}
|
||||
showLibrarySourceDialog = false
|
||||
},
|
||||
onDismiss = { showLibrarySourceDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showWatchProgressDialog) {
|
||||
WatchProgressSourceDialog(
|
||||
selectedSource = settingsUiState.watchProgressSource,
|
||||
onSourceSelected = { source ->
|
||||
TraktSettingsRepository.setWatchProgressSource(source)
|
||||
statusMessage = if (source == WatchProgressSource.TRAKT) {
|
||||
traktProgressSelectedMessage
|
||||
} else {
|
||||
nuvioProgressSelectedMessage
|
||||
}
|
||||
showWatchProgressDialog = false
|
||||
},
|
||||
onDismiss = { showWatchProgressDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showContinueWatchingWindowDialog) {
|
||||
ContinueWatchingWindowDialog(
|
||||
selectedDaysCap = settingsUiState.continueWatchingDaysCap,
|
||||
onDaysCapSelected = { days ->
|
||||
TraktSettingsRepository.setContinueWatchingDaysCap(days)
|
||||
showContinueWatchingWindowDialog = false
|
||||
},
|
||||
onDismiss = { showContinueWatchingWindowDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraktSettingsActionRow(
|
||||
title: String,
|
||||
description: String,
|
||||
value: String,
|
||||
isTablet: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val verticalPadding = if (isTablet) 16.dp else 14.dp
|
||||
val horizontalPadding = if (isTablet) 20.dp else 16.dp
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 12.dp)
|
||||
.widthIn(max = if (isTablet) 560.dp else Dp.Unspecified),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraktInfoRow(
|
||||
isTablet: Boolean,
|
||||
text: String,
|
||||
) {
|
||||
val horizontalPadding = if (isTablet) 20.dp else 16.dp
|
||||
val verticalPadding = if (isTablet) 14.dp else 12.dp
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun librarySourceModeLabel(source: LibrarySourceMode): String =
|
||||
when (source) {
|
||||
LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt)
|
||||
LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun watchProgressSourceLabel(source: WatchProgressSource): String =
|
||||
when (source) {
|
||||
WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt)
|
||||
WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun continueWatchingDaysCapLabel(daysCap: Int): String {
|
||||
val normalized = normalizeTraktContinueWatchingDaysCap(daysCap)
|
||||
return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) {
|
||||
stringResource(Res.string.trakt_all_history)
|
||||
} else {
|
||||
stringResource(Res.string.trakt_days_format, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun LibrarySourceModeDialog(
|
||||
selectedSource: LibrarySourceMode,
|
||||
onSourceSelected: (LibrarySourceMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_library_source_dialog_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_library_source_dialog_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source ->
|
||||
TraktDialogOption(
|
||||
label = librarySourceModeLabel(source),
|
||||
selected = source == selectedSource,
|
||||
onClick = { onSourceSelected(source) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_dialog_close),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun WatchProgressSourceDialog(
|
||||
selectedSource: WatchProgressSource,
|
||||
onSourceSelected: (WatchProgressSource) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_watch_progress_dialog_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source ->
|
||||
TraktDialogOption(
|
||||
label = watchProgressSourceLabel(source),
|
||||
selected = source == selectedSource,
|
||||
onClick = { onSourceSelected(source) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_dialog_close),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun ContinueWatchingWindowDialog(
|
||||
selectedDaysCap: Int,
|
||||
onDaysCapSelected: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap)
|
||||
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_cw_window_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_cw_window_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TraktContinueWatchingDaysOptions.forEach { days ->
|
||||
val normalizedDays = normalizeTraktContinueWatchingDaysCap(days)
|
||||
TraktDialogOption(
|
||||
label = continueWatchingDaysCapLabel(days),
|
||||
selected = normalizedDays == normalizedSelected,
|
||||
onClick = { onDaysCapSelected(days) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_dialog_close),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraktDialogOption(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = containerColor,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1052,6 +1052,7 @@ object TmdbMetadataService {
|
|||
posterShape = PosterShape.Poster,
|
||||
description = recommendation.overview?.trim()?.takeIf(String::isNotBlank),
|
||||
releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4),
|
||||
rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate,
|
||||
imdbRating = recommendation.voteAverage?.formatRating(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1087,6 +1088,7 @@ object TmdbMetadataService {
|
|||
posterShape = PosterShape.Landscape,
|
||||
description = part.overview?.trim()?.takeIf(String::isNotBlank),
|
||||
releaseInfo = part.releaseDate?.take(4),
|
||||
rawReleaseDate = part.releaseDate,
|
||||
imdbRating = part.voteAverage?.formatRating(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ private const val LIST_FETCH_CONCURRENCY = 4
|
|||
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
||||
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
|
||||
private const val FORCE_REFRESH_DEDUP_MS = 10_000L
|
||||
private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240
|
||||
|
||||
data class TraktLibraryUiState(
|
||||
val listTabs: List<TraktListTab> = emptyList(),
|
||||
|
|
@ -159,21 +160,20 @@ object TraktLibraryRepository {
|
|||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
}
|
||||
result.exceptionOrNull()?.let { error ->
|
||||
if (error is CancellationException) throw error
|
||||
log.w { "Failed to refresh Trakt library: ${error.message}" }
|
||||
}.getOrNull()
|
||||
|
||||
if (result == null) {
|
||||
_uiState.value = current.copy(
|
||||
log.w(error) { "Failed to refresh Trakt library" }
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
hasLoaded = true,
|
||||
errorMessage = getString(Res.string.trakt_library_load_failed),
|
||||
errorMessage = traktLibraryLoadErrorMessage(error),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.value = result.copy(
|
||||
val snapshot = result.getOrThrow()
|
||||
_uiState.value = snapshot.copy(
|
||||
isLoading = false,
|
||||
hasLoaded = true,
|
||||
errorMessage = null,
|
||||
|
|
@ -414,6 +414,27 @@ object TraktLibraryRepository {
|
|||
TraktLibraryStorage.savePayload(json.encodeToString(payload))
|
||||
}
|
||||
|
||||
private suspend fun traktLibraryLoadErrorMessage(error: Throwable): String {
|
||||
val fallback = getString(Res.string.trakt_library_load_failed)
|
||||
val detail = error.userVisibleMessage()
|
||||
return when {
|
||||
detail.isBlank() -> fallback
|
||||
detail.equals(fallback, ignoreCase = true) -> fallback
|
||||
else -> detail
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.userVisibleMessage(): String {
|
||||
val raw = message?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: toString().trim()
|
||||
val firstLine = raw.lines().firstOrNull()?.trim().orEmpty()
|
||||
return if (firstLine.length <= MAX_VISIBLE_ERROR_MESSAGE_LENGTH) {
|
||||
firstLine
|
||||
} else {
|
||||
firstLine.take(MAX_VISIBLE_ERROR_MESSAGE_LENGTH).trimEnd() + "..."
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
|
||||
val watchlistTabs = listOf(
|
||||
TraktListTab(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
|
|||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val BASE_URL = "https://api.trakt.tv"
|
||||
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 80f
|
||||
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f
|
||||
private const val HISTORY_LIMIT = 250
|
||||
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
|
||||
private const val METADATA_FETCH_CONCURRENCY = 5
|
||||
|
|
@ -113,8 +118,8 @@ object TraktProgressRepository {
|
|||
}
|
||||
|
||||
scope.launch {
|
||||
val historyEntries = runCatching {
|
||||
fetchHistoryEntries(headers)
|
||||
val completedEntries = runCatching {
|
||||
fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
|
||||
|
|
@ -122,7 +127,7 @@ object TraktProgressRepository {
|
|||
|
||||
if (!isLatestRefreshRequest(requestId)) return@launch
|
||||
|
||||
val merged = mergeNewestByVideoId(playbackEntries + historyEntries)
|
||||
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
|
||||
isLoading = false,
|
||||
|
|
@ -345,12 +350,32 @@ object TraktProgressRepository {
|
|||
mergeNewestByVideoId(completedEpisodes + completedMovies)
|
||||
}
|
||||
|
||||
private suspend fun fetchWatchedShowSeedEntries(
|
||||
headers: Map<String, String>,
|
||||
): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode
|
||||
val payload = httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/sync/watched/shows",
|
||||
headers = headers,
|
||||
)
|
||||
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
|
||||
watchedShows
|
||||
.mapNotNull { item ->
|
||||
mapWatchedShowSeed(
|
||||
item = item,
|
||||
useFurthestEpisode = useFurthestEpisode,
|
||||
)
|
||||
}
|
||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
||||
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
|
||||
entries.forEach { rawEntry ->
|
||||
val entry = rawEntry.normalizedCompletion()
|
||||
val existing = mergedByVideoId[entry.videoId]
|
||||
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
|
||||
if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
|
||||
mergedByVideoId[entry.videoId] = entry
|
||||
}
|
||||
}
|
||||
|
|
@ -360,6 +385,18 @@ object TraktProgressRepository {
|
|||
.sortedByDescending { it.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
private fun shouldReplaceProgressSnapshotEntry(
|
||||
existing: WatchProgressEntry,
|
||||
candidate: WatchProgressEntry,
|
||||
): Boolean {
|
||||
val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching()
|
||||
val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching()
|
||||
if (existingInProgress != candidateInProgress) {
|
||||
return candidateInProgress
|
||||
}
|
||||
return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs
|
||||
}
|
||||
|
||||
private fun mergeEntriesPreferRichMetadata(
|
||||
current: List<WatchProgressEntry>,
|
||||
hydrated: List<WatchProgressEntry>,
|
||||
|
|
@ -499,6 +536,7 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||
progressPercent = progressPercent,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
).normalizedCompletion()
|
||||
}
|
||||
|
||||
|
|
@ -533,6 +571,7 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||
progressPercent = progressPercent,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
).normalizedCompletion()
|
||||
}
|
||||
|
||||
|
|
@ -564,6 +603,7 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceTraktHistory,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -583,6 +623,73 @@ object TraktProgressRepository {
|
|||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceTraktHistory,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapWatchedShowSeed(
|
||||
item: TraktWatchedShowItem,
|
||||
useFurthestEpisode: Boolean,
|
||||
): WatchProgressEntry? {
|
||||
val show = item.show ?: return null
|
||||
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
||||
if (parentMetaId.isBlank()) return null
|
||||
|
||||
val completedEpisode = item.seasons.orEmpty()
|
||||
.asSequence()
|
||||
.filter { season -> (season.number ?: 0) > 0 }
|
||||
.flatMap { season ->
|
||||
val seasonNumber = season.number ?: return@flatMap emptySequence()
|
||||
season.episodes.orEmpty()
|
||||
.asSequence()
|
||||
.filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 }
|
||||
.mapNotNull { episode ->
|
||||
val episodeNumber = episode.number ?: return@mapNotNull null
|
||||
TraktWatchedShowEpisodeSeed(
|
||||
season = seasonNumber,
|
||||
episode = episodeNumber,
|
||||
watchedAt = rankedTimestamp(
|
||||
isoDate = episode.lastWatchedAt ?: item.lastWatchedAt,
|
||||
fallbackIndex = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
.maxWithOrNull(
|
||||
if (useFurthestEpisode) {
|
||||
compareBy<TraktWatchedShowEpisodeSeed>(
|
||||
{ it.season },
|
||||
{ it.episode },
|
||||
{ it.watchedAt },
|
||||
)
|
||||
} else {
|
||||
compareBy<TraktWatchedShowEpisodeSeed>(
|
||||
{ it.watchedAt },
|
||||
{ it.season },
|
||||
{ it.episode },
|
||||
)
|
||||
},
|
||||
) ?: return null
|
||||
|
||||
return WatchProgressEntry(
|
||||
contentType = "series",
|
||||
parentMetaId = parentMetaId,
|
||||
parentMetaType = "series",
|
||||
videoId = buildPlaybackVideoId(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = completedEpisode.season,
|
||||
episodeNumber = completedEpisode.episode,
|
||||
fallbackVideoId = null,
|
||||
),
|
||||
title = show.title ?: parentMetaId,
|
||||
seasonNumber = completedEpisode.season,
|
||||
episodeNumber = completedEpisode.episode,
|
||||
lastPositionMs = 1L,
|
||||
durationMs = 1L,
|
||||
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
||||
isCompleted = true,
|
||||
progressPercent = 100f,
|
||||
source = WatchProgressSourceTraktShowProgress,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -597,14 +704,10 @@ object TraktProgressRepository {
|
|||
}
|
||||
|
||||
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
|
||||
val compactDigits = isoDate
|
||||
?.filter(Char::isDigit)
|
||||
?.take(14)
|
||||
?.takeIf { it.length >= 8 }
|
||||
?.padEnd(14, '0')
|
||||
?.toLongOrNull()
|
||||
if (compactDigits != null) return compactDigits
|
||||
|
||||
isoDate
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||
?.let { return it }
|
||||
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
||||
}
|
||||
}
|
||||
|
|
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
|
|||
@SerialName("movie") val movie: TraktMedia? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktWatchedShowItem(
|
||||
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
|
||||
@SerialName("show") val show: TraktMedia? = null,
|
||||
@SerialName("seasons") val seasons: List<TraktWatchedShowSeason>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktWatchedShowSeason(
|
||||
@SerialName("number") val number: Int? = null,
|
||||
@SerialName("episodes") val episodes: List<TraktWatchedShowEpisode>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktWatchedShowEpisode(
|
||||
@SerialName("number") val number: Int? = null,
|
||||
@SerialName("plays") val plays: Int? = null,
|
||||
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
|
||||
)
|
||||
|
||||
private data class TraktWatchedShowEpisodeSeed(
|
||||
val season: Int,
|
||||
val episode: Int,
|
||||
val watchedAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktMedia(
|
||||
@SerialName("title") val title: String? = null,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
internal expect object TraktSettingsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.watchedKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
|
|||
val WatchedItem.isEpisode: Boolean
|
||||
get() = season != null && episode != null
|
||||
|
||||
internal fun WatchedItem.normalizedMarkedAt(): WatchedItem {
|
||||
val normalized = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs)
|
||||
return if (normalized == markedAtEpochMs) this else copy(markedAtEpochMs = normalized)
|
||||
}
|
||||
|
||||
internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long {
|
||||
if (value !in CompactWatchedTimestampMin..CompactWatchedTimestampMax) return value
|
||||
|
||||
val raw = value.toString().padStart(14, '0')
|
||||
val year = raw.substring(0, 4).toIntOrNull() ?: return value
|
||||
val month = raw.substring(4, 6).toIntOrNull() ?: return value
|
||||
val day = raw.substring(6, 8).toIntOrNull() ?: return value
|
||||
val hour = raw.substring(8, 10).toIntOrNull() ?: return value
|
||||
val minute = raw.substring(10, 12).toIntOrNull() ?: return value
|
||||
val second = raw.substring(12, 14).toIntOrNull() ?: return value
|
||||
|
||||
if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) {
|
||||
return value
|
||||
}
|
||||
|
||||
val iso = buildString {
|
||||
append(year.toString().padStart(4, '0'))
|
||||
append('-')
|
||||
append(month.toString().padStart(2, '0'))
|
||||
append('-')
|
||||
append(day.toString().padStart(2, '0'))
|
||||
append('T')
|
||||
append(hour.toString().padStart(2, '0'))
|
||||
append(':')
|
||||
append(minute.toString().padStart(2, '0'))
|
||||
append(':')
|
||||
append(second.toString().padStart(2, '0'))
|
||||
append('Z')
|
||||
}
|
||||
return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value
|
||||
}
|
||||
|
||||
fun watchedItemKey(
|
||||
type: String,
|
||||
id: String,
|
||||
|
|
@ -47,3 +85,5 @@ fun watchedItemKey(
|
|||
episodeNumber = episode,
|
||||
)
|
||||
|
||||
private const val CompactWatchedTimestampMin = 19000101000000L
|
||||
private const val CompactWatchedTimestampMax = 29991231235959L
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.WatchProgressSource
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
|
||||
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
|
||||
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
|
||||
|
|
@ -42,8 +45,8 @@ object WatchedRepository {
|
|||
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
||||
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
||||
|
||||
private fun activeSyncAdapter(): WatchedSyncAdapter =
|
||||
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter
|
||||
private fun activePullSyncAdapter(): WatchedSyncAdapter =
|
||||
if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -72,21 +75,27 @@ object WatchedRepository {
|
|||
val items = runCatching {
|
||||
json.decodeFromString<StoredWatchedPayload>(payload).items
|
||||
}.getOrDefault(emptyList())
|
||||
itemsByKey = items.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap()
|
||||
itemsByKey = items
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||
.toMutableMap()
|
||||
}
|
||||
|
||||
publish()
|
||||
}
|
||||
|
||||
suspend fun pullFromServer(profileId: Int) {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
currentProfileId = profileId
|
||||
runCatching {
|
||||
val serverItems = activeSyncAdapter().pull(
|
||||
val serverItems = activePullSyncAdapter().pull(
|
||||
profileId = profileId,
|
||||
pageSize = watchedItemsPageSize,
|
||||
)
|
||||
|
||||
itemsByKey = serverItems
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||
.toMutableMap()
|
||||
hasLoaded = true
|
||||
|
|
@ -203,7 +212,7 @@ object WatchedRepository {
|
|||
runCatching {
|
||||
if (items.isEmpty()) return@runCatching
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
activeSyncAdapter().push(profileId = profileId, items = items)
|
||||
pushToActiveTargets(profileId = profileId, items = items)
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to push watched items" }
|
||||
}
|
||||
|
|
@ -215,7 +224,7 @@ object WatchedRepository {
|
|||
runCatching {
|
||||
if (items.isEmpty()) return@runCatching
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
activeSyncAdapter().delete(profileId = profileId, items = items)
|
||||
deleteFromActiveTargets(profileId = profileId, items = items)
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to push watched item delete" }
|
||||
}
|
||||
|
|
@ -223,7 +232,9 @@ object WatchedRepository {
|
|||
}
|
||||
|
||||
private fun publish() {
|
||||
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }
|
||||
val items = itemsByKey.values
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.sortedByDescending { it.markedAtEpochMs }
|
||||
_uiState.value = WatchedUiState(
|
||||
items = items,
|
||||
watchedKeys = items.mapTo(linkedSetOf()) {
|
||||
|
|
@ -238,9 +249,55 @@ object WatchedRepository {
|
|||
currentProfileId,
|
||||
json.encodeToString(
|
||||
StoredWatchedPayload(
|
||||
items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs },
|
||||
items = itemsByKey.values
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.sortedByDescending { it.markedAtEpochMs },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldUseTraktWatchedSync(): Boolean =
|
||||
shouldUseTraktWatchedSync(
|
||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||
)
|
||||
|
||||
private suspend fun pushToActiveTargets(
|
||||
profileId: Int,
|
||||
items: Collection<WatchedItem>,
|
||||
) {
|
||||
if (shouldUseTraktWatchedSync()) {
|
||||
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
|
||||
return
|
||||
}
|
||||
|
||||
syncAdapter.push(profileId = profileId, items = items)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteFromActiveTargets(
|
||||
profileId: Int,
|
||||
items: Collection<WatchedItem>,
|
||||
) {
|
||||
if (shouldUseTraktWatchedSync()) {
|
||||
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
|
||||
return
|
||||
}
|
||||
|
||||
syncAdapter.delete(profileId = profileId, items = items)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldUseTraktWatchedSync(
|
||||
isAuthenticated: Boolean,
|
||||
source: WatchProgressSource,
|
||||
): Boolean = shouldUseTraktProgress(
|
||||
isAuthenticated = isAuthenticated,
|
||||
source = source,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ package com.nuvio.app.features.watching.application
|
|||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import com.nuvio.app.features.watched.watchedItemKey
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.continueWatchingEntries
|
||||
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
|
||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
|
||||
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
|
||||
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
|
||||
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
||||
|
||||
object WatchingState {
|
||||
|
|
@ -59,7 +61,9 @@ object WatchingState {
|
|||
add(WatchingContentRef(type = item.type, id = item.id))
|
||||
}
|
||||
}
|
||||
val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord)
|
||||
val progressRecords = progressEntries
|
||||
.filter { entry -> entry.shouldUseAsCompletedSeedForContinueWatching() }
|
||||
.map(WatchProgressEntry::toDomainProgressRecord)
|
||||
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
|
||||
return contentRefs.mapNotNull { content ->
|
||||
latestCompletedSeriesEpisode(
|
||||
|
|
@ -73,21 +77,9 @@ object WatchingState {
|
|||
|
||||
fun visibleContinueWatchingEntries(
|
||||
progressEntries: List<WatchProgressEntry>,
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
|
||||
): List<WatchProgressEntry> {
|
||||
val visibleIds = continueWatchingProgressEntries(
|
||||
progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||
)
|
||||
.filter { record ->
|
||||
val latestCompleted = latestCompletedBySeries[record.content]
|
||||
latestCompleted == null || record.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs
|
||||
}
|
||||
.mapTo(linkedSetOf()) { record -> record.videoId }
|
||||
|
||||
return progressEntries
|
||||
.filter { entry -> entry.videoId in visibleIds }
|
||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||
}
|
||||
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
|
||||
}
|
||||
|
||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||
|
|
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
|||
content = WatchingContentRef(type = type, id = id),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
markedAtEpochMs = markedAtEpochMs,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
|||
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
|
||||
val params = buildJsonObject { put("p_profile_id", profileId) }
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
|
||||
return result.decodeList<WatchProgressSyncEntry>().map { entry ->
|
||||
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
|
||||
val records = serverEntries.map { entry ->
|
||||
ProgressSyncRecord(
|
||||
contentId = entry.contentId,
|
||||
contentType = entry.contentType,
|
||||
|
|
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
|||
lastWatched = entry.lastWatched,
|
||||
)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
override suspend fun push(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.sync
|
|||
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.serialization.SerialName
|
||||
|
|
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
|||
name = syncItem.title,
|
||||
season = syncItem.season,
|
||||
episode = syncItem.episode,
|
||||
markedAtEpochMs = syncItem.watchedAt,
|
||||
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
|||
title = item.name,
|
||||
season = item.season,
|
||||
episode = item.episode,
|
||||
watchedAt = item.markedAtEpochMs,
|
||||
watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
|
||||
)
|
||||
}
|
||||
val params = buildJsonObject {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
|||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
|
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
|||
}
|
||||
|
||||
private fun rankedTimestamp(isoDate: String?): Long {
|
||||
val digits = isoDate
|
||||
?.filter(Char::isDigit)
|
||||
?.take(14)
|
||||
?.takeIf { it.length >= 8 }
|
||||
?.padEnd(14, '0')
|
||||
?.toLongOrNull()
|
||||
return digits ?: 0L
|
||||
return isoDate
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||
?: 0L
|
||||
}
|
||||
|
||||
private fun epochMsToIso(epochMs: Long): String {
|
||||
// Convert to a compact ISO 8601 UTC string.
|
||||
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places,
|
||||
// or a real epoch-ms. We only send when it looks like real epoch-ms.
|
||||
if (epochMs <= 0L) return "unknown"
|
||||
if (epochMs < 10_000_000_000L) {
|
||||
// Looks like seconds-based or ranked timestamp — send unknown
|
||||
return "unknown"
|
||||
}
|
||||
val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
|
||||
if (normalizedEpochMs <= 0L) return "unknown"
|
||||
if (normalizedEpochMs < 10_000_000_000L) return "unknown"
|
||||
// Real epoch ms → simple ISO via arithmetic
|
||||
val totalSeconds = epochMs / 1000
|
||||
val totalSeconds = normalizedEpochMs / 1000
|
||||
val s = (totalSeconds % 60).toInt()
|
||||
val m = ((totalSeconds / 60) % 60).toInt()
|
||||
val h = ((totalSeconds / 3600) % 24).toInt()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ data class CachedNextUpItem(
|
|||
val episodeTitle: String? = null,
|
||||
val episodeThumbnail: String? = null,
|
||||
val pauseDescription: String? = null,
|
||||
val released: String? = null,
|
||||
val hasAired: Boolean = true,
|
||||
val lastWatched: Long,
|
||||
val sortTimestamp: Long,
|
||||
val seedSeason: Int? = null,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -13,6 +14,12 @@ private data class StoredContinueWatchingPreferences(
|
|||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
@SerialName("use_episode_thumbnails_in_cw")
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
@SerialName("show_unaired_next_up")
|
||||
val showUnairedNextUp: Boolean = true,
|
||||
@SerialName("blur_continue_watching_next_up")
|
||||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
)
|
||||
|
|
@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible: Boolean,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
upNextFromFurthestEpisode: Boolean,
|
||||
useEpisodeThumbnails: Boolean = true,
|
||||
showUnairedNextUp: Boolean = true,
|
||||
blurNextUp: Boolean = false,
|
||||
dismissedNextUpKeys: Set<String>,
|
||||
) {
|
||||
ensureLoaded()
|
||||
|
|
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = isVisible,
|
||||
style = style,
|
||||
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
blurNextUp = blurNextUp,
|
||||
dismissedNextUpKeys = dismissedNextUpKeys
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
|
|
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = stored.isVisible,
|
||||
style = stored.style,
|
||||
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = stored.useEpisodeThumbnails,
|
||||
showUnairedNextUp = stored.showUnairedNextUp,
|
||||
blurNextUp = stored.blurNextUp,
|
||||
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
||||
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
||||
)
|
||||
|
|
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setUseEpisodeThumbnails(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(useEpisodeThumbnails = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setShowUnairedNextUp(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(showUnairedNextUp = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setBlurNextUp(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(blurNextUp = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun addDismissedNextUpKey(key: String) {
|
||||
ensureLoaded()
|
||||
val normalizedKey = key.trim()
|
||||
|
|
@ -139,6 +173,9 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = _uiState.value.isVisible,
|
||||
style = _uiState.value.style,
|
||||
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
||||
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
|
||||
showUnairedNextUp = _uiState.value.showUnairedNextUp,
|
||||
blurNextUp = _uiState.value.blurNextUp,
|
||||
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
||||
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo
|
|||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal const val WatchProgressCompletionPercentThreshold = 99.5f
|
||||
internal const val WatchProgressCompletionPercentThreshold = 90f
|
||||
internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f
|
||||
internal const val WatchProgressSourceLocal = "local"
|
||||
internal const val WatchProgressSourceTraktPlayback = "trakt_playback"
|
||||
internal const val WatchProgressSourceTraktHistory = "trakt_history"
|
||||
internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress"
|
||||
|
||||
@Serializable
|
||||
enum class ContinueWatchingSectionStyle {
|
||||
|
|
@ -37,6 +42,7 @@ data class WatchProgressEntry(
|
|||
val lastSourceUrl: String? = null,
|
||||
val isCompleted: Boolean = false,
|
||||
val progressPercent: Float? = null,
|
||||
val source: String = WatchProgressSourceLocal,
|
||||
) {
|
||||
val normalizedProgressPercent: Float?
|
||||
get() = progressPercent?.coerceIn(0f, 100f)
|
||||
|
|
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
|
|||
val episodeTitle: String? = null,
|
||||
val episodeThumbnail: String? = null,
|
||||
val pauseDescription: String? = null,
|
||||
val released: String? = null,
|
||||
val isNextUp: Boolean = false,
|
||||
val nextUpSeedSeasonNumber: Int? = null,
|
||||
val nextUpSeedEpisodeNumber: Int? = null,
|
||||
|
|
@ -163,6 +170,9 @@ data class ContinueWatchingPreferencesUiState(
|
|||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
val showUnairedNextUp: Boolean = true,
|
||||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
)
|
||||
|
|
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
|||
episodeTitle = normalizedEntry.episodeTitle,
|
||||
episodeThumbnail = normalizedEntry.episodeThumbnail,
|
||||
pauseDescription = normalizedEntry.pauseDescription,
|
||||
released = null,
|
||||
isNextUp = false,
|
||||
nextUpSeedSeasonNumber = null,
|
||||
nextUpSeedEpisodeNumber = null,
|
||||
|
|
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
|||
episodeTitle = nextEpisode.title,
|
||||
episodeThumbnail = nextEpisode.thumbnail,
|
||||
pauseDescription = nextEpisode.overview,
|
||||
released = nextEpisode.released,
|
||||
isNextUp = true,
|
||||
nextUpSeedSeasonNumber = seasonNumber,
|
||||
nextUpSeedEpisodeNumber = episodeNumber,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import com.nuvio.app.features.player.PlayerPlaybackSnapshot
|
|||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktProgressRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
|
||||
import com.nuvio.app.features.watching.application.WatchingActions
|
||||
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
|
||||
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
|
||||
|
|
@ -37,7 +39,11 @@ object WatchProgressRepository {
|
|||
init {
|
||||
syncScope.launch {
|
||||
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
||||
if (authenticated) {
|
||||
if (shouldUseTraktProgressSource(
|
||||
isAuthenticated = authenticated,
|
||||
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||
)
|
||||
) {
|
||||
runCatching { TraktProgressRepository.refreshNow() }
|
||||
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } }
|
||||
}
|
||||
|
|
@ -45,9 +51,23 @@ object WatchProgressRepository {
|
|||
}
|
||||
}
|
||||
|
||||
syncScope.launch {
|
||||
TraktSettingsRepository.uiState.collectLatest { settings ->
|
||||
if (shouldUseTraktProgressSource(
|
||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||
source = settings.watchProgressSource,
|
||||
)
|
||||
) {
|
||||
runCatching { TraktProgressRepository.refreshNow() }
|
||||
.onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } }
|
||||
}
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
syncScope.launch {
|
||||
TraktProgressRepository.uiState.collectLatest {
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (shouldUseTraktProgress()) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
|
@ -56,19 +76,21 @@ object WatchProgressRepository {
|
|||
|
||||
fun ensureLoaded() {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktProgressRepository.ensureLoaded()
|
||||
if (hasLoaded) return
|
||||
loadFromDisk(ProfileRepository.activeProfileId)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (shouldUseTraktProgress()) {
|
||||
TraktProgressRepository.refreshAsync()
|
||||
}
|
||||
}
|
||||
|
||||
fun onProfileChanged(profileId: Int) {
|
||||
if (profileId == currentProfileId && hasLoaded) return
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
loadFromDisk(profileId)
|
||||
TraktProgressRepository.onProfileChanged()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (shouldUseTraktProgress()) {
|
||||
TraktProgressRepository.refreshAsync()
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +101,7 @@ object WatchProgressRepository {
|
|||
currentProfileId = 1
|
||||
entriesByVideoId.clear()
|
||||
TraktProgressRepository.clearLocalState()
|
||||
TraktSettingsRepository.clearLocalState()
|
||||
_uiState.value = WatchProgressUiState()
|
||||
}
|
||||
|
||||
|
|
@ -98,9 +121,14 @@ object WatchProgressRepository {
|
|||
}
|
||||
|
||||
suspend fun pullFromServer(profileId: Int) {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktProgressRepository.ensureLoaded()
|
||||
currentProfileId = profileId
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val useTraktProgress = shouldUseTraktProgress()
|
||||
|
||||
if (useTraktProgress) {
|
||||
runCatching { TraktProgressRepository.refreshNow() }
|
||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
|
||||
publish()
|
||||
|
|
@ -368,7 +396,6 @@ object WatchProgressRepository {
|
|||
}
|
||||
|
||||
private fun pushScrobbleToServer(entry: WatchProgressEntry) {
|
||||
if (shouldUseTraktProgress()) return
|
||||
syncScope.launch {
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
|
|
@ -394,8 +421,9 @@ object WatchProgressRepository {
|
|||
|
||||
private fun publish() {
|
||||
val entries = currentEntries()
|
||||
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
|
||||
_uiState.value = WatchProgressUiState(
|
||||
entries = entries.sortedByDescending { it.lastUpdatedEpochMs },
|
||||
entries = sortedEntries,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -406,7 +434,11 @@ object WatchProgressRepository {
|
|||
)
|
||||
}
|
||||
|
||||
private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value
|
||||
private fun shouldUseTraktProgress(): Boolean =
|
||||
shouldUseTraktProgressSource(
|
||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||
)
|
||||
|
||||
private fun currentEntries(): List<WatchProgressEntry> {
|
||||
return if (shouldUseTraktProgress()) {
|
||||
|
|
|
|||
|
|
@ -67,15 +67,50 @@ internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): Watc
|
|||
internal fun List<WatchProgressEntry>.continueWatchingEntries(
|
||||
limit: Int = ContinueWatchingLimit,
|
||||
): List<WatchProgressEntry> {
|
||||
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
|
||||
val domainEntries = continueWatchingProgressEntries(
|
||||
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
|
||||
progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||
limit = limit,
|
||||
)
|
||||
val ids = domainEntries.map { record -> record.videoId }.toSet()
|
||||
return filter { entry -> entry.videoId in ids }
|
||||
return inProgressEntries.filter { entry -> entry.videoId in ids }
|
||||
.sortedByDescending { it.lastUpdatedEpochMs }
|
||||
}
|
||||
|
||||
internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Boolean {
|
||||
val entry = normalizedCompletion()
|
||||
if (entry.isEffectivelyCompleted) return false
|
||||
|
||||
val hasStartedPlayback = entry.lastPositionMs > 0L ||
|
||||
entry.normalizedProgressPercent?.let { it > 0f } == true
|
||||
if (!hasStartedPlayback) return false
|
||||
|
||||
return entry.source != WatchProgressSourceTraktHistory &&
|
||||
entry.source != WatchProgressSourceTraktShowProgress
|
||||
}
|
||||
|
||||
internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean {
|
||||
val entry = normalizedCompletion()
|
||||
if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false
|
||||
if (!entry.isEffectivelyCompleted) return false
|
||||
if (entry.source != WatchProgressSourceTraktPlayback) return true
|
||||
|
||||
val explicitPercent = entry.normalizedProgressPercent ?: return false
|
||||
return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold
|
||||
}
|
||||
|
||||
internal fun String?.isSeriesTypeForContinueWatching(): Boolean =
|
||||
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
|
||||
|
||||
internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
|
||||
val trimmed = contentId?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
return when (trimmed.lowercase()) {
|
||||
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||
normalizedCompletion().let { entry ->
|
||||
WatchingProgressRecord(
|
||||
|
|
|
|||
|
|
@ -49,4 +49,26 @@ class HomeCatalogParserTest {
|
|||
result.items.map { it.stableKey() },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse catalog response keeps raw released date for unreleased filtering`() {
|
||||
val result = HomeCatalogParser.parseCatalogResponse(
|
||||
payload = """
|
||||
{
|
||||
"metas": [
|
||||
{
|
||||
"id": "tt1",
|
||||
"type": "movie",
|
||||
"name": "Future Movie",
|
||||
"releaseInfo": "2027",
|
||||
"released": "2027-05-12T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("2027", result.items.single().releaseInfo)
|
||||
assertEquals("2027-05-12T00:00:00.000Z", result.items.single().rawReleaseDate)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.home
|
|||
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
|
|
@ -60,6 +61,91 @@ class HomeScreenTest {
|
|||
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build home continue watching items suppresses next up when series has in progress resume`() {
|
||||
val inProgress = progressEntry(
|
||||
videoId = "show:1:4",
|
||||
title = "Show",
|
||||
episodeNumber = 4,
|
||||
episodeTitle = "Current",
|
||||
lastUpdatedEpochMs = 200L,
|
||||
)
|
||||
val nextUp = continueWatchingItem(
|
||||
videoId = "show:1:5",
|
||||
subtitle = "Up Next • S1E5 • Next",
|
||||
)
|
||||
|
||||
val result = buildHomeContinueWatchingItems(
|
||||
visibleEntries = listOf(inProgress),
|
||||
nextUpItemsBySeries = mapOf("show" to (500L to nextUp)),
|
||||
)
|
||||
|
||||
assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId))
|
||||
assertEquals("S1E4 • Current", result.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
||||
val oldEntry = progressEntry(
|
||||
videoId = "old",
|
||||
title = "Old",
|
||||
lastUpdatedEpochMs = 1_000L,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null,
|
||||
)
|
||||
val recentEntry = progressEntry(
|
||||
videoId = "recent",
|
||||
title = "Recent",
|
||||
lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null,
|
||||
)
|
||||
val entries = listOf(oldEntry, recentEntry)
|
||||
|
||||
val filtered = filterEntriesForTraktContinueWatchingWindow(
|
||||
entries = entries,
|
||||
isTraktProgressActive = true,
|
||||
daysCap = 60,
|
||||
nowEpochMs = 90L * MILLIS_PER_DAY,
|
||||
)
|
||||
val nuvioSource = filterEntriesForTraktContinueWatchingWindow(
|
||||
entries = entries,
|
||||
isTraktProgressActive = false,
|
||||
daysCap = 60,
|
||||
nowEpochMs = 90L * MILLIS_PER_DAY,
|
||||
)
|
||||
|
||||
assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId))
|
||||
assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt all history window keeps old progress`() {
|
||||
val oldEntry = progressEntry(
|
||||
videoId = "old",
|
||||
title = "Old",
|
||||
lastUpdatedEpochMs = 1_000L,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null,
|
||||
)
|
||||
val recentEntry = progressEntry(
|
||||
videoId = "recent",
|
||||
title = "Recent",
|
||||
lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
|
||||
seasonNumber = null,
|
||||
episodeNumber = null,
|
||||
)
|
||||
|
||||
val result = filterEntriesForTraktContinueWatchingWindow(
|
||||
entries = listOf(oldEntry, recentEntry),
|
||||
isTraktProgressActive = true,
|
||||
daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
|
||||
nowEpochMs = 90L * MILLIS_PER_DAY,
|
||||
)
|
||||
|
||||
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
|
||||
}
|
||||
|
||||
private fun progressEntry(
|
||||
videoId: String,
|
||||
title: String,
|
||||
|
|
@ -100,4 +186,8 @@ class HomeScreenTest {
|
|||
durationMs = 0L,
|
||||
progressFraction = 0f,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktListType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
|
|
@ -37,4 +39,34 @@ class LibraryRepositoryTest {
|
|||
assertEquals(PosterShape.Poster, preview.posterShape)
|
||||
assertEquals("banner", preview.banner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library tabs include local Nuvio library before Trakt tabs`() {
|
||||
val traktTab = TraktListTab(
|
||||
key = "trakt:watchlist",
|
||||
title = "Watchlist",
|
||||
type = TraktListType.WATCHLIST,
|
||||
)
|
||||
|
||||
val tabs = libraryTabsWithLocal(listOf(traktTab))
|
||||
|
||||
assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key })
|
||||
assertEquals("Nuvio Library", tabs.first().title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library membership always includes local state before Trakt membership`() {
|
||||
val membership = libraryMembershipWithLocal(
|
||||
inLocal = true,
|
||||
traktMembership = mapOf("trakt:watchlist" to false),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"local" to true,
|
||||
"trakt:watchlist" to false,
|
||||
),
|
||||
membership,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -118,6 +118,61 @@ class WatchProgressRulesTest {
|
|||
assertEquals(listOf("movie-progress"), result.map { it.videoId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continue watching keeps active resume even when a newer episode is completed`() {
|
||||
val inProgress = entry(
|
||||
videoId = "show:1:4",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
lastUpdatedEpochMs = 10L,
|
||||
)
|
||||
val completed = entry(
|
||||
videoId = "show:1:5",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 5,
|
||||
lastUpdatedEpochMs = 20L,
|
||||
isCompleted = true,
|
||||
)
|
||||
|
||||
val result = listOf(inProgress, completed).continueWatchingEntries()
|
||||
|
||||
assertEquals(listOf("show:1:4"), result.map { it.videoId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt playback next up seeds require TV percent threshold`() {
|
||||
val belowSeedThreshold = entry(
|
||||
videoId = "show:1:4",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
progressPercent = 94f,
|
||||
source = WatchProgressSourceTraktPlayback,
|
||||
)
|
||||
val seed = belowSeedThreshold.copy(progressPercent = 95f)
|
||||
|
||||
assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching())
|
||||
assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Trakt history is not treated as active resume`() {
|
||||
val history = entry(
|
||||
videoId = "show:1:4",
|
||||
parentMetaId = "show",
|
||||
seasonNumber = 1,
|
||||
episodeNumber = 4,
|
||||
lastPositionMs = 1L,
|
||||
durationMs = 0L,
|
||||
progressPercent = 50f,
|
||||
source = WatchProgressSourceTraktHistory,
|
||||
)
|
||||
|
||||
assertFalse(history.shouldTreatAsInProgressForContinueWatching())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `codec normalizes completed entries inferred from percent`() {
|
||||
val payload = WatchProgressCodec.encodeEntries(
|
||||
|
|
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
|
|||
durationMs: Long = 1_000_000L,
|
||||
isCompleted: Boolean = false,
|
||||
progressPercent: Float? = null,
|
||||
source: String = WatchProgressSourceLocal,
|
||||
): WatchProgressEntry =
|
||||
WatchProgressEntry(
|
||||
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
||||
|
|
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
|
|||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
isCompleted = isCompleted,
|
||||
progressPercent = progressPercent,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"mdblist_use_audience",
|
||||
"trakt_auth_payload",
|
||||
"trakt_library_payload",
|
||||
"trakt_settings_payload",
|
||||
"collections_payload",
|
||||
)
|
||||
|
||||
actual fun wipe() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -13,8 +13,13 @@ import platform.Foundation.NSUserDefaults
|
|||
actual object ThemeSettingsStorage {
|
||||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||
private const val selectedAppLanguageKey = "selected_app_language"
|
||||
private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey)
|
||||
private val profileScopedSyncKeys = listOf(
|
||||
selectedThemeKey,
|
||||
amoledEnabledKey,
|
||||
liquidGlassNativeTabBarEnabledKey,
|
||||
)
|
||||
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
||||
|
||||
actual fun loadSelectedTheme(): String? =
|
||||
|
|
@ -38,6 +43,23 @@ actual object ThemeSettingsStorage {
|
|||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey))
|
||||
}
|
||||
|
||||
actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
|
||||
return if (defaults.objectForKey(key) != null) {
|
||||
defaults.boolForKey(key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
|
||||
NSUserDefaults.standardUserDefaults.setBool(
|
||||
enabled,
|
||||
forKey = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey),
|
||||
)
|
||||
}
|
||||
|
||||
actual fun loadSelectedAppLanguage(): String? {
|
||||
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
|
||||
if (value != null) return value
|
||||
|
|
@ -65,6 +87,7 @@ actual object ThemeSettingsStorage {
|
|||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +101,7 @@ actual object ThemeSettingsStorage {
|
|||
|
||||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=50
|
||||
MARKETING_VERSION=0.1.13
|
||||
CURRENT_PROJECT_VERSION=54
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,316 @@ import UIKit
|
|||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
final class RootComposeViewController: UIViewController {
|
||||
private enum NuvioNativeTabIcon {
|
||||
static let home = vectorIcon(
|
||||
viewport: CGSize(width: 24, height: 24),
|
||||
paths: [
|
||||
"M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z",
|
||||
]
|
||||
)
|
||||
|
||||
static let search = drawnIcon { context, rect in
|
||||
drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) {
|
||||
context.setStrokeColor(UIColor.black.cgColor)
|
||||
context.setLineWidth(2)
|
||||
context.setLineCap(.round)
|
||||
context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12))
|
||||
context.move(to: CGPoint(x: 13.6, y: 13.6))
|
||||
context.addLine(to: CGPoint(x: 17, y: 17))
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
static let library = vectorIcon(
|
||||
viewport: CGSize(width: 24, height: 24),
|
||||
paths: [
|
||||
"M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z",
|
||||
"M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z",
|
||||
"M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z",
|
||||
]
|
||||
)
|
||||
|
||||
static let profileFallback = vectorIcon(
|
||||
viewport: CGSize(width: 24, height: 24),
|
||||
paths: [
|
||||
"M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z",
|
||||
]
|
||||
)
|
||||
|
||||
static func profileAvatar(
|
||||
name: String?,
|
||||
avatarColor: UIColor?,
|
||||
backgroundColor: UIColor?,
|
||||
avatarImage: UIImage?,
|
||||
selected: Bool,
|
||||
accent: UIColor
|
||||
) -> UIImage {
|
||||
guard name != nil || avatarColor != nil || avatarImage != nil else {
|
||||
return profileFallback
|
||||
}
|
||||
|
||||
let size = CGSize(width: 28, height: 28)
|
||||
let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
|
||||
let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15)
|
||||
let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5)
|
||||
let initial = name?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.prefix(1)
|
||||
.uppercased() ?? ""
|
||||
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1)
|
||||
fillColor.setFill()
|
||||
UIBezierPath(ovalIn: rect).fill()
|
||||
|
||||
if let avatarImage {
|
||||
UIBezierPath(ovalIn: rect).addClip()
|
||||
drawAspectFill(image: avatarImage, in: rect)
|
||||
} else if !initial.isEmpty {
|
||||
let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold)
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: baseColor,
|
||||
]
|
||||
let textSize = initial.size(withAttributes: attributes)
|
||||
initial.draw(
|
||||
at: CGPoint(
|
||||
x: rect.midX - textSize.width / 2,
|
||||
y: rect.midY - textSize.height / 2
|
||||
),
|
||||
withAttributes: attributes
|
||||
)
|
||||
} else {
|
||||
profileFallback
|
||||
.withTintColor(baseColor, renderingMode: .alwaysOriginal)
|
||||
.draw(in: rect.insetBy(dx: 5.5, dy: 5.5))
|
||||
}
|
||||
|
||||
borderColor.setStroke()
|
||||
let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75))
|
||||
borderPath.lineWidth = 1.5
|
||||
borderPath.stroke()
|
||||
}.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
|
||||
private static func drawInViewport(
|
||||
context: CGContext,
|
||||
rect: CGRect,
|
||||
viewport: CGSize,
|
||||
draw: () -> Void
|
||||
) {
|
||||
let scale = min(rect.width / viewport.width, rect.height / viewport.height)
|
||||
let x = rect.midX - viewport.width * scale / 2
|
||||
let y = rect.midY - viewport.height * scale / 2
|
||||
context.saveGState()
|
||||
context.translateBy(x: x, y: y)
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
draw()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage {
|
||||
drawnIcon(size: size) { context, rect in
|
||||
drawInViewport(context: context, rect: rect, viewport: viewport) {
|
||||
context.setFillColor(UIColor.black.cgColor)
|
||||
paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in
|
||||
context.addPath(path)
|
||||
context.fillPath(using: .evenOdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawnIcon(
|
||||
size: CGSize = CGSize(width: 25, height: 25),
|
||||
draw: @escaping (CGContext, CGRect) -> Void
|
||||
) -> UIImage {
|
||||
UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
draw(rendererContext.cgContext, CGRect(origin: .zero, size: size))
|
||||
}.withRenderingMode(.alwaysTemplate)
|
||||
}
|
||||
|
||||
private static func drawAspectFill(image: UIImage, in rect: CGRect) {
|
||||
guard image.size.width > 0, image.size.height > 0 else { return }
|
||||
let scale = max(rect.width / image.size.width, rect.height / image.size.height)
|
||||
let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
let drawRect = CGRect(
|
||||
x: rect.midX - drawSize.width / 2,
|
||||
y: rect.midY - drawSize.height / 2,
|
||||
width: drawSize.width,
|
||||
height: drawSize.height
|
||||
)
|
||||
image.draw(in: drawRect)
|
||||
}
|
||||
|
||||
private struct SVGPath {
|
||||
private enum Token {
|
||||
case command(Character)
|
||||
case number(CGFloat)
|
||||
}
|
||||
|
||||
let data: String
|
||||
|
||||
var cgPath: CGPath? {
|
||||
let tokens = Self.tokens(from: data)
|
||||
var index = 0
|
||||
var command: Character?
|
||||
var current = CGPoint.zero
|
||||
var subpathStart = CGPoint.zero
|
||||
let path = CGMutablePath()
|
||||
|
||||
func hasNumber() -> Bool {
|
||||
guard index < tokens.count else { return false }
|
||||
if case .number = tokens[index] { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func readNumber() -> CGFloat? {
|
||||
guard index < tokens.count else { return nil }
|
||||
guard case let .number(value) = tokens[index] else { return nil }
|
||||
index += 1
|
||||
return value
|
||||
}
|
||||
|
||||
func readPoint(relative: Bool) -> CGPoint? {
|
||||
guard let x = readNumber(), let y = readNumber() else { return nil }
|
||||
let point = CGPoint(x: x, y: y)
|
||||
return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point
|
||||
}
|
||||
|
||||
while index < tokens.count {
|
||||
if case let .command(value) = tokens[index] {
|
||||
command = value
|
||||
index += 1
|
||||
}
|
||||
|
||||
guard let activeCommand = command else { return nil }
|
||||
let relative = activeCommand.isLowercase
|
||||
|
||||
switch activeCommand.uppercased() {
|
||||
case "M":
|
||||
guard let point = readPoint(relative: relative) else { return nil }
|
||||
path.move(to: point)
|
||||
current = point
|
||||
subpathStart = point
|
||||
command = relative ? "l" : "L"
|
||||
case "L":
|
||||
while hasNumber() {
|
||||
guard let point = readPoint(relative: relative) else { return nil }
|
||||
path.addLine(to: point)
|
||||
current = point
|
||||
}
|
||||
case "H":
|
||||
while hasNumber() {
|
||||
guard let x = readNumber() else { return nil }
|
||||
let point = CGPoint(x: relative ? current.x + x : x, y: current.y)
|
||||
path.addLine(to: point)
|
||||
current = point
|
||||
}
|
||||
case "V":
|
||||
while hasNumber() {
|
||||
guard let y = readNumber() else { return nil }
|
||||
let point = CGPoint(x: current.x, y: relative ? current.y + y : y)
|
||||
path.addLine(to: point)
|
||||
current = point
|
||||
}
|
||||
case "C":
|
||||
while hasNumber() {
|
||||
guard
|
||||
let c1 = readPoint(relative: relative),
|
||||
let c2 = readPoint(relative: relative),
|
||||
let end = readPoint(relative: relative)
|
||||
else { return nil }
|
||||
path.addCurve(to: end, control1: c1, control2: c2)
|
||||
current = end
|
||||
}
|
||||
case "Z":
|
||||
path.closeSubpath()
|
||||
current = subpathStart
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
private static func tokens(from data: String) -> [Token] {
|
||||
let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||
let range = NSRange(data.startIndex..<data.endIndex, in: data)
|
||||
return regex.matches(in: data, range: range).compactMap { match in
|
||||
guard let tokenRange = Range(match.range, in: data) else { return nil }
|
||||
let token = String(data[tokenRange])
|
||||
if token.count == 1, let character = token.first, character.isLetter {
|
||||
return .command(character)
|
||||
}
|
||||
guard let value = Double(token) else { return nil }
|
||||
return .number(CGFloat(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||
private enum NativeTab: String, CaseIterable {
|
||||
case home = "Home"
|
||||
case search = "Search"
|
||||
case library = "Library"
|
||||
case settings = "Settings"
|
||||
|
||||
var tag: Int {
|
||||
switch self {
|
||||
case .home: return 0
|
||||
case .search: return 1
|
||||
case .library: return 2
|
||||
case .settings: return 3
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: return "Home"
|
||||
case .search: return "Search"
|
||||
case .library: return "Library"
|
||||
case .settings: return "Profile"
|
||||
}
|
||||
}
|
||||
|
||||
var iconImage: UIImage {
|
||||
switch self {
|
||||
case .home: return NuvioNativeTabIcon.home
|
||||
case .search: return NuvioNativeTabIcon.search
|
||||
case .library: return NuvioNativeTabIcon.library
|
||||
case .settings: return NuvioNativeTabIcon.profileFallback
|
||||
}
|
||||
}
|
||||
|
||||
init?(tag: Int) {
|
||||
guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil }
|
||||
self = tab
|
||||
}
|
||||
}
|
||||
|
||||
private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
|
||||
private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
|
||||
private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
|
||||
private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
|
||||
private static let nativeProfileNameKey = "NuvioNativeProfileName"
|
||||
private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
|
||||
private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL"
|
||||
private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
|
||||
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
|
||||
|
||||
private let contentController: UIViewController
|
||||
private let tabBar = UITabBar()
|
||||
private var contentBottomToViewBottom: NSLayoutConstraint?
|
||||
private var tabBarHeightConstraint: NSLayoutConstraint?
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
private var tabChromeObserver: NSObjectProtocol?
|
||||
private var profileAvatarImageURL: String?
|
||||
private var profileAvatarImageTask: URLSessionDataTask?
|
||||
private var profileAvatarImage: UIImage?
|
||||
|
||||
init(contentController: UIViewController) {
|
||||
self.contentController = contentController
|
||||
|
|
@ -20,17 +328,45 @@ final class RootComposeViewController: UIViewController {
|
|||
|
||||
view.backgroundColor = .black
|
||||
contentController.view.backgroundColor = .black
|
||||
UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey)
|
||||
|
||||
addChild(contentController)
|
||||
view.addSubview(contentController.view)
|
||||
contentController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
self.contentBottomToViewBottom = bottomToViewBottom
|
||||
NSLayoutConstraint.activate([
|
||||
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
bottomToViewBottom,
|
||||
])
|
||||
contentController.didMove(toParent: self)
|
||||
|
||||
configureNativeTabBar()
|
||||
installNativeTabObservers()
|
||||
syncNativeTabChrome(animated: false)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let userDefaultsObserver {
|
||||
NotificationCenter.default.removeObserver(userDefaultsObserver)
|
||||
}
|
||||
if let tabChromeObserver {
|
||||
NotificationCenter.default.removeObserver(tabChromeObserver)
|
||||
}
|
||||
profileAvatarImageTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
updateTabBarHeight()
|
||||
}
|
||||
|
||||
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
||||
guard let tab = NativeTab(tag: item.tag) else { return }
|
||||
UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey)
|
||||
NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue)
|
||||
}
|
||||
|
||||
override var childForHomeIndicatorAutoHidden: UIViewController? {
|
||||
|
|
@ -88,6 +424,210 @@ final class RootComposeViewController: UIViewController {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var nativeTabsSupported: Bool {
|
||||
UIDevice.current.userInterfaceIdiom == .phone &&
|
||||
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
}
|
||||
|
||||
private var shouldShowNativeTabBar: Bool {
|
||||
nativeTabsSupported &&
|
||||
UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) &&
|
||||
UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey)
|
||||
}
|
||||
|
||||
private func configureNativeTabBar() {
|
||||
tabBar.delegate = self
|
||||
tabBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
tabBar.items = NativeTab.allCases.map { tab in
|
||||
let item = UITabBarItem(
|
||||
title: tab.title,
|
||||
image: tab.iconImage,
|
||||
selectedImage: tab.iconImage
|
||||
)
|
||||
item.tag = tab.tag
|
||||
return item
|
||||
}
|
||||
tabBar.selectedItem = tabBar.items?.first
|
||||
applyNativeTabBarAppearance()
|
||||
tabBar.alpha = 0
|
||||
tabBar.isHidden = true
|
||||
|
||||
view.addSubview(tabBar)
|
||||
let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight)
|
||||
tabBarHeightConstraint = heightConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
heightConstraint,
|
||||
])
|
||||
}
|
||||
|
||||
private func installNativeTabObservers() {
|
||||
userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.syncNativeTabChrome(animated: true)
|
||||
}
|
||||
|
||||
tabChromeObserver = NotificationCenter.default.addObserver(
|
||||
forName: Self.nativeTabChromeDidChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.syncNativeTabChrome(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBarHeight: CGFloat {
|
||||
49 + view.safeAreaInsets.bottom
|
||||
}
|
||||
|
||||
private func updateTabBarHeight() {
|
||||
tabBarHeightConstraint?.constant = tabBarHeight
|
||||
}
|
||||
|
||||
private func syncNativeTabChrome(animated: Bool) {
|
||||
updateTabBarHeight()
|
||||
applyNativeTabBarAppearance()
|
||||
syncSelectedNativeTab()
|
||||
|
||||
let visible = shouldShowNativeTabBar
|
||||
contentBottomToViewBottom?.isActive = true
|
||||
if visible {
|
||||
tabBar.isHidden = false
|
||||
}
|
||||
|
||||
let changes = {
|
||||
self.tabBar.alpha = visible ? 1 : 0
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
let completion: (Bool) -> Void = { _ in
|
||||
self.tabBar.isHidden = !visible
|
||||
}
|
||||
|
||||
if animated && view.window != nil {
|
||||
UIView.animate(
|
||||
withDuration: 0.22,
|
||||
delay: 0,
|
||||
options: [.beginFromCurrentState, .curveEaseInOut],
|
||||
animations: changes,
|
||||
completion: completion
|
||||
)
|
||||
} else {
|
||||
changes()
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncSelectedNativeTab() {
|
||||
let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue
|
||||
let selectedTab = NativeTab(rawValue: rawValue) ?? .home
|
||||
tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
|
||||
}
|
||||
|
||||
private func applyNativeTabBarAppearance() {
|
||||
let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ??
|
||||
UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1)
|
||||
let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1)
|
||||
|
||||
refreshProfileAvatarImageIfNeeded()
|
||||
updateNativeTabImages(accent: accent)
|
||||
|
||||
tabBar.tintColor = accent
|
||||
tabBar.unselectedItemTintColor = unselected
|
||||
|
||||
let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance
|
||||
appearance.stackedLayoutAppearance.normal.iconColor = unselected
|
||||
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||
appearance.stackedLayoutAppearance.selected.iconColor = accent
|
||||
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||
appearance.inlineLayoutAppearance.normal.iconColor = unselected
|
||||
appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||
appearance.inlineLayoutAppearance.selected.iconColor = accent
|
||||
appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||
appearance.compactInlineLayoutAppearance.normal.iconColor = unselected
|
||||
appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||
appearance.compactInlineLayoutAppearance.selected.iconColor = accent
|
||||
appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||
tabBar.standardAppearance = appearance
|
||||
tabBar.scrollEdgeAppearance = appearance
|
||||
}
|
||||
|
||||
private func updateNativeTabImages(accent: UIColor) {
|
||||
tabBar.items?.forEach { item in
|
||||
guard let tab = NativeTab(tag: item.tag) else { return }
|
||||
item.image = nativeTabImage(for: tab, selected: false, accent: accent)
|
||||
item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent)
|
||||
}
|
||||
}
|
||||
|
||||
private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage {
|
||||
guard tab == .settings else {
|
||||
return tab.iconImage
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
return NuvioNativeTabIcon.profileAvatar(
|
||||
name: defaults.string(forKey: Self.nativeProfileNameKey),
|
||||
avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)),
|
||||
backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)),
|
||||
avatarImage: profileAvatarImage,
|
||||
selected: selected,
|
||||
accent: accent
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshProfileAvatarImageIfNeeded() {
|
||||
let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey)
|
||||
guard urlString != profileAvatarImageURL else { return }
|
||||
|
||||
profileAvatarImageTask?.cancel()
|
||||
profileAvatarImageTask = nil
|
||||
profileAvatarImageURL = urlString
|
||||
profileAvatarImage = nil
|
||||
|
||||
guard let urlString, let url = URL(string: urlString) else { return }
|
||||
|
||||
profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard
|
||||
let self,
|
||||
let data,
|
||||
let image = UIImage(data: data)
|
||||
else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self.profileAvatarImageURL == urlString else { return }
|
||||
self.profileAvatarImage = image
|
||||
self.applyNativeTabBarAppearance()
|
||||
}
|
||||
}
|
||||
profileAvatarImageTask?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
convenience init?(hexString: String?) {
|
||||
guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if value.hasPrefix("#") {
|
||||
value.removeFirst()
|
||||
}
|
||||
guard value.count == 6, let rgb = UInt64(value, radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
red: CGFloat((rgb >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((rgb >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(rgb & 0xFF) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: UIViewControllerRepresentable {
|
||||
|
|
|
|||
Loading…
Reference in a new issue