mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +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 {
|
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
resolve("AppVersionConfig.kt").writeText(
|
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.TraktAuthStorage
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||||
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
import com.nuvio.app.features.trakt.TraktLibraryStorage
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||||
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
||||||
|
|
@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
TraktAuthStorage.initialize(applicationContext)
|
TraktAuthStorage.initialize(applicationContext)
|
||||||
TraktCommentsStorage.initialize(applicationContext)
|
TraktCommentsStorage.initialize(applicationContext)
|
||||||
TraktLibraryStorage.initialize(applicationContext)
|
TraktLibraryStorage.initialize(applicationContext)
|
||||||
|
TraktSettingsStorage.initialize(applicationContext)
|
||||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||||
ResumePromptStorage.initialize(applicationContext)
|
ResumePromptStorage.initialize(applicationContext)
|
||||||
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
|
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"nuvio_mdblist_settings",
|
"nuvio_mdblist_settings",
|
||||||
"nuvio_trakt_auth",
|
"nuvio_trakt_auth",
|
||||||
"nuvio_trakt_library",
|
"nuvio_trakt_library",
|
||||||
|
"nuvio_trakt_settings",
|
||||||
"nuvio_watched",
|
"nuvio_watched",
|
||||||
"nuvio_stream_link_cache",
|
"nuvio_stream_link_cache",
|
||||||
"nuvio_continue_watching_preferences",
|
"nuvio_continue_watching_preferences",
|
||||||
"nuvio_episode_release_notifications",
|
"nuvio_episode_release_notifications",
|
||||||
"nuvio_episode_release_notifications_platform",
|
"nuvio_episode_release_notifications_platform",
|
||||||
"nuvio_watch_progress",
|
"nuvio_watch_progress",
|
||||||
|
"nuvio_collections",
|
||||||
"nuvio_plugins",
|
"nuvio_plugins",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 preferencesName = "nuvio_theme_settings"
|
||||||
private const val selectedThemeKey = "selected_theme"
|
private const val selectedThemeKey = "selected_theme"
|
||||||
private const val amoledEnabledKey = "amoled_enabled"
|
private const val amoledEnabledKey = "amoled_enabled"
|
||||||
|
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||||
private const val selectedAppLanguageKey = "selected_app_language"
|
private const val selectedAppLanguageKey = "selected_app_language"
|
||||||
private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey)
|
private val profileScopedSyncKeys = listOf(
|
||||||
|
selectedThemeKey,
|
||||||
|
amoledEnabledKey,
|
||||||
|
liquidGlassNativeTabBarEnabledKey,
|
||||||
|
)
|
||||||
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
||||||
|
|
||||||
private var preferences: SharedPreferences? = null
|
private var preferences: SharedPreferences? = null
|
||||||
|
|
@ -51,6 +56,19 @@ actual object ThemeSettingsStorage {
|
||||||
?.apply()
|
?.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? =
|
||||||
|
preferences?.let { prefs ->
|
||||||
|
val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
|
||||||
|
if (prefs.contains(key)) prefs.getBoolean(key, false) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.putBoolean(ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled)
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
actual fun loadSelectedAppLanguage(): String? {
|
actual fun loadSelectedAppLanguage(): String? {
|
||||||
val value = preferences?.getString(selectedAppLanguageKey, null)
|
val value = preferences?.getString(selectedAppLanguageKey, null)
|
||||||
if (value != null) return value
|
if (value != null) return value
|
||||||
|
|
@ -75,6 +93,7 @@ actual object ThemeSettingsStorage {
|
||||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||||
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +105,7 @@ actual object ThemeSettingsStorage {
|
||||||
|
|
||||||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||||
|
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||||
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
||||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">App Language</string>
|
||||||
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
|
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
|
||||||
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
|
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
|
||||||
|
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||||
|
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
|
||||||
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
|
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
|
||||||
<string name="settings_appearance_section_display">DISPLAY</string>
|
<string name="settings_appearance_section_display">DISPLAY</string>
|
||||||
<string name="settings_appearance_section_home">HOME</string>
|
<string name="settings_appearance_section_home">HOME</string>
|
||||||
|
|
@ -475,6 +477,8 @@
|
||||||
<string name="settings_homescreen_selected_count">%1$d of %2$d selected</string>
|
<string name="settings_homescreen_selected_count">%1$d of %2$d selected</string>
|
||||||
<string name="settings_homescreen_show_hero">Show Hero Section</string>
|
<string name="settings_homescreen_show_hero">Show Hero Section</string>
|
||||||
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
|
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
|
||||||
|
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
|
||||||
|
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
||||||
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
|
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
|
||||||
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
|
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
|
||||||
<string name="settings_homescreen_visible">Visible</string>
|
<string name="settings_homescreen_visible">Visible</string>
|
||||||
|
|
@ -506,6 +510,10 @@
|
||||||
<string name="settings_show_secret">Show value</string>
|
<string name="settings_show_secret">Show value</string>
|
||||||
<string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
|
<string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
|
||||||
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
|
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
|
||||||
|
<string name="settings_continue_watching_blur_next_up_description">Blur next episode thumbnails in Continue Watching to avoid spoilers.</string>
|
||||||
|
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
|
||||||
|
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
|
||||||
|
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
|
||||||
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
|
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
|
||||||
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
|
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
|
||||||
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
|
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
|
||||||
|
|
@ -518,6 +526,8 @@
|
||||||
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
|
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
|
||||||
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
|
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
|
||||||
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
|
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
|
||||||
|
<string name="settings_continue_watching_use_episode_thumbnails_description">Prefer episode thumbnails when available.</string>
|
||||||
|
<string name="settings_continue_watching_use_episode_thumbnails_title">Prefer Episode Thumbnails in Continue Watching</string>
|
||||||
<string name="settings_content_discovery_section_home">HOME</string>
|
<string name="settings_content_discovery_section_home">HOME</string>
|
||||||
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
||||||
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
|
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
|
||||||
|
|
@ -557,6 +567,8 @@
|
||||||
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
|
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
|
||||||
<string name="settings_meta_episodes">Episodes</string>
|
<string name="settings_meta_episodes">Episodes</string>
|
||||||
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
|
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
|
||||||
|
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
|
||||||
|
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
|
||||||
<string name="settings_meta_group_label">Group %1$d</string>
|
<string name="settings_meta_group_label">Group %1$d</string>
|
||||||
<string name="settings_meta_more_like_this">More like this</string>
|
<string name="settings_meta_more_like_this">More like this</string>
|
||||||
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
|
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
|
||||||
|
|
@ -783,6 +795,28 @@
|
||||||
<string name="settings_trakt_open_login">Open Trakt Login</string>
|
<string name="settings_trakt_open_login">Open Trakt Login</string>
|
||||||
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
|
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
|
||||||
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
|
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
|
||||||
|
<string name="trakt_library_source_title">Library Source</string>
|
||||||
|
<string name="trakt_library_source_subtitle">Choose which library to use for saving and viewing your collection</string>
|
||||||
|
<string name="trakt_library_source_dialog_title">Library Source</string>
|
||||||
|
<string name="trakt_library_source_dialog_subtitle">Choose where to save and manage your library items</string>
|
||||||
|
<string name="trakt_library_source_trakt">Trakt</string>
|
||||||
|
<string name="trakt_library_source_nuvio">Nuvio Library</string>
|
||||||
|
<string name="trakt_library_source_trakt_selected">Trakt library selected</string>
|
||||||
|
<string name="trakt_library_source_nuvio_selected">Nuvio library selected</string>
|
||||||
|
<string name="trakt_watch_progress_title">Watch Progress</string>
|
||||||
|
<string name="trakt_watch_progress_subtitle">Choose which progress source powers resume and continue watching</string>
|
||||||
|
<string name="trakt_watch_progress_dialog_title">Watch Progress</string>
|
||||||
|
<string name="trakt_watch_progress_dialog_subtitle">Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active.</string>
|
||||||
|
<string name="trakt_watch_progress_source_trakt">Trakt</string>
|
||||||
|
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
|
||||||
|
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
|
||||||
|
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
|
||||||
|
<string name="trakt_continue_watching_window">Continue Watching Window</string>
|
||||||
|
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
|
||||||
|
<string name="trakt_cw_window_title">Continue Watching Window</string>
|
||||||
|
<string name="trakt_cw_window_subtitle">Choose how much Trakt activity should appear in continue watching.</string>
|
||||||
|
<string name="trakt_all_history">All history</string>
|
||||||
|
<string name="trakt_days_format">%1$d days</string>
|
||||||
<string name="source_audience_score">Audience Score</string>
|
<string name="source_audience_score">Audience Score</string>
|
||||||
<string name="source_imdb">IMDb</string>
|
<string name="source_imdb">IMDb</string>
|
||||||
<string name="source_letterboxd">Letterboxd</string>
|
<string name="source_letterboxd">Letterboxd</string>
|
||||||
|
|
@ -973,9 +1007,14 @@
|
||||||
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
|
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
|
||||||
<string name="profile_avatar_options_pending">Avatar options will appear here when the catalog loads.</string>
|
<string name="profile_avatar_options_pending">Avatar options will appear here when the catalog loads.</string>
|
||||||
<string name="profile_avatar_selected">Avatar: %1$s</string>
|
<string name="profile_avatar_selected">Avatar: %1$s</string>
|
||||||
|
<string name="profile_avatar_url_invalid">Enter a valid http:// or https:// image URL.</string>
|
||||||
<string name="profile_choose_avatar">Choose an avatar</string>
|
<string name="profile_choose_avatar">Choose an avatar</string>
|
||||||
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
|
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
|
||||||
<string name="profile_create_profile">Create Profile</string>
|
<string name="profile_create_profile">Create Profile</string>
|
||||||
|
<string name="profile_custom_avatar_selected">Custom avatar URL selected.</string>
|
||||||
|
<string name="profile_custom_avatar_url">Custom avatar URL</string>
|
||||||
|
<string name="profile_custom_avatar_url_description">Paste an image link, or leave this empty to use the built-in avatar catalog.</string>
|
||||||
|
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||||
<string name="profile_delete_confirm_message">All data for "%1$s" will be permanently deleted.</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_delete_title">Delete Profile</string>
|
||||||
<string name="profile_edit_add_title">Add 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.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -60,6 +61,8 @@ import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
|
@ -92,6 +95,10 @@ import com.nuvio.app.core.ui.NuvioToastController
|
||||||
import com.nuvio.app.core.ui.NuvioFloatingPrompt
|
import com.nuvio.app.core.ui.NuvioFloatingPrompt
|
||||||
import com.nuvio.app.core.ui.TraktListPickerDialog
|
import com.nuvio.app.core.ui.TraktListPickerDialog
|
||||||
import com.nuvio.app.core.ui.NuvioTheme
|
import com.nuvio.app.core.ui.NuvioTheme
|
||||||
|
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||||
|
import com.nuvio.app.core.ui.NativeNavigationTab
|
||||||
|
import com.nuvio.app.core.ui.NativeTabBridge
|
||||||
|
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
|
||||||
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
||||||
import com.nuvio.app.features.auth.AuthScreen
|
import com.nuvio.app.features.auth.AuthScreen
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
|
@ -122,11 +129,13 @@ import com.nuvio.app.features.player.PlayerRoute
|
||||||
import com.nuvio.app.features.player.PlayerScreen
|
import com.nuvio.app.features.player.PlayerScreen
|
||||||
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
||||||
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
||||||
|
import com.nuvio.app.features.profiles.AvatarRepository
|
||||||
import com.nuvio.app.features.profiles.NuvioProfile
|
import com.nuvio.app.features.profiles.NuvioProfile
|
||||||
import com.nuvio.app.features.profiles.ProfileEditScreen
|
import com.nuvio.app.features.profiles.ProfileEditScreen
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
||||||
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
||||||
|
import com.nuvio.app.features.profiles.profileAvatarImageUrl
|
||||||
import com.nuvio.app.features.search.SearchScreen
|
import com.nuvio.app.features.search.SearchScreen
|
||||||
import com.nuvio.app.features.settings.SettingsScreen
|
import com.nuvio.app.features.settings.SettingsScreen
|
||||||
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
||||||
|
|
@ -152,8 +161,6 @@ import com.nuvio.app.features.streams.StreamsRepository
|
||||||
import com.nuvio.app.features.streams.StreamsScreen
|
import com.nuvio.app.features.streams.StreamsScreen
|
||||||
import com.nuvio.app.features.tmdb.TmdbService
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
|
||||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
|
||||||
import com.nuvio.app.features.trakt.TraktListTab
|
import com.nuvio.app.features.trakt.TraktListTab
|
||||||
import com.nuvio.app.features.updater.AppUpdaterHost
|
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||||
|
|
@ -262,6 +269,20 @@ enum class AppScreenTab {
|
||||||
Settings,
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
|
||||||
|
AppScreenTab.Home -> NativeNavigationTab.Home
|
||||||
|
AppScreenTab.Search -> NativeNavigationTab.Search
|
||||||
|
AppScreenTab.Library -> NativeNavigationTab.Library
|
||||||
|
AppScreenTab.Settings -> NativeNavigationTab.Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
|
||||||
|
NativeNavigationTab.Home -> AppScreenTab.Home
|
||||||
|
NativeNavigationTab.Search -> AppScreenTab.Search
|
||||||
|
NativeNavigationTab.Library -> AppScreenTab.Library
|
||||||
|
NativeNavigationTab.Settings -> AppScreenTab.Settings
|
||||||
|
}
|
||||||
|
|
||||||
private enum class AppGateScreen {
|
private enum class AppGateScreen {
|
||||||
Loading,
|
Loading,
|
||||||
Auth,
|
Auth,
|
||||||
|
|
@ -295,13 +316,36 @@ fun App() {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
NetworkStatusRepository.ensureStarted()
|
NetworkStatusRepository.ensureStarted()
|
||||||
ProfileRepository.loadCachedProfiles()
|
ProfileRepository.loadCachedProfiles()
|
||||||
|
AvatarRepository.fetchAvatars()
|
||||||
}
|
}
|
||||||
|
|
||||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||||
|
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by remember {
|
val networkStatusUiState by remember {
|
||||||
NetworkStatusRepository.uiState
|
NetworkStatusRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
profileState.activeProfile?.profileIndex,
|
||||||
|
profileState.activeProfile?.name,
|
||||||
|
profileState.activeProfile?.avatarColorHex,
|
||||||
|
profileState.activeProfile?.avatarId,
|
||||||
|
profileState.activeProfile?.avatarUrl,
|
||||||
|
profileAvatars,
|
||||||
|
) {
|
||||||
|
val activeProfile = profileState.activeProfile
|
||||||
|
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
|
||||||
|
profileAvatars.find { it.id == avatarId }
|
||||||
|
}
|
||||||
|
NativeTabBridge.publishProfileTabIcon(
|
||||||
|
name = activeProfile?.name,
|
||||||
|
avatarColorHex = activeProfile?.avatarColorHex,
|
||||||
|
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
|
||||||
|
avatarBackgroundColorHex = avatarItem?.bgColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
|
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
|
||||||
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||||
var isNewProfile by remember { mutableStateOf(false) }
|
var isNewProfile by remember { mutableStateOf(false) }
|
||||||
|
|
@ -468,6 +512,11 @@ private fun MainAppContent(
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||||
|
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
||||||
|
val liquidGlassNativeTabBarEnabled by remember {
|
||||||
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||||
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
|
var selectedPosterForActions by remember { mutableStateOf<MetaPreview?>(null) }
|
||||||
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
|
||||||
|
|
@ -486,10 +535,6 @@ private fun MainAppContent(
|
||||||
LibraryRepository.ensureLoaded()
|
LibraryRepository.ensureLoaded()
|
||||||
LibraryRepository.uiState
|
LibraryRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val traktAuthUiState by remember {
|
|
||||||
TraktAuthRepository.ensureLoaded()
|
|
||||||
TraktAuthRepository.uiState
|
|
||||||
}.collectAsStateWithLifecycle()
|
|
||||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||||
val playerSettingsUiState by remember {
|
val playerSettingsUiState by remember {
|
||||||
|
|
@ -508,7 +553,7 @@ private fun MainAppContent(
|
||||||
NetworkStatusRepository.uiState
|
NetworkStatusRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
|
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
|
||||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
||||||
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
||||||
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
||||||
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -521,6 +566,42 @@ private fun MainAppContent(
|
||||||
.sorted()
|
.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(nativeRequestedTab) {
|
||||||
|
if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) {
|
||||||
|
selectedTab = nativeRequestedTab.toAppScreenTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(selectedTab) {
|
||||||
|
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(
|
||||||
|
navController,
|
||||||
|
liquidGlassNativeTabBarSupported,
|
||||||
|
liquidGlassNativeTabBarEnabled,
|
||||||
|
initialHomeReady,
|
||||||
|
) {
|
||||||
|
fun publishNativeTabVisibilityForCurrentRoute() {
|
||||||
|
val visible = liquidGlassNativeTabBarSupported &&
|
||||||
|
liquidGlassNativeTabBarEnabled &&
|
||||||
|
initialHomeReady &&
|
||||||
|
navController.currentDestination?.hasRoute<TabsRoute>() == true
|
||||||
|
NativeTabBridge.publishTabBarVisible(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ ->
|
||||||
|
publishNativeTabVisibilityForCurrentRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
publishNativeTabVisibilityForCurrentRoute()
|
||||||
|
navController.addOnDestinationChangedListener(destinationChangedListener)
|
||||||
|
onDispose {
|
||||||
|
navController.removeOnDestinationChangedListener(destinationChangedListener)
|
||||||
|
NativeTabBridge.publishTabBarVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
NetworkStatusRepository.ensureStarted()
|
NetworkStatusRepository.ensureStarted()
|
||||||
EpisodeReleaseNotificationsRepository.refreshAsync()
|
EpisodeReleaseNotificationsRepository.refreshAsync()
|
||||||
|
|
@ -892,6 +973,8 @@ private fun MainAppContent(
|
||||||
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val isTabletLayout = maxWidth >= 768.dp
|
val isTabletLayout = maxWidth >= 768.dp
|
||||||
|
val useNativeBottomTabs =
|
||||||
|
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||||
profileSwitchLoading = true
|
profileSwitchLoading = true
|
||||||
selectedTab = AppScreenTab.Home
|
selectedTab = AppScreenTab.Home
|
||||||
|
|
@ -906,7 +989,7 @@ private fun MainAppContent(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
contentWindowInsets = WindowInsets(0),
|
contentWindowInsets = WindowInsets(0),
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (!isTabletLayout) {
|
if (!isTabletLayout && !useNativeBottomTabs) {
|
||||||
NuvioNavigationBar {
|
NuvioNavigationBar {
|
||||||
NavItem(
|
NavItem(
|
||||||
selected = selectedTab == AppScreenTab.Home,
|
selected = selectedTab == AppScreenTab.Home,
|
||||||
|
|
@ -942,58 +1025,62 @@ private fun MainAppContent(
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AppTabHost(
|
CompositionLocalProvider(
|
||||||
modifier = Modifier
|
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
|
||||||
.fillMaxSize()
|
) {
|
||||||
.padding(innerPadding),
|
AppTabHost(
|
||||||
selectedTab = selectedTab,
|
modifier = Modifier
|
||||||
onCatalogClick = onCatalogClick,
|
.fillMaxSize()
|
||||||
onPosterClick = { meta ->
|
.padding(innerPadding),
|
||||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
selectedTab = selectedTab,
|
||||||
},
|
onCatalogClick = onCatalogClick,
|
||||||
onPosterLongClick = { meta ->
|
onPosterClick = { meta ->
|
||||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||||
selectedPosterForActions = meta
|
},
|
||||||
},
|
onPosterLongClick = { meta ->
|
||||||
onLibraryPosterClick = { item ->
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
selectedPosterForActions = meta
|
||||||
},
|
},
|
||||||
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
onLibraryPosterClick = { item ->
|
||||||
onContinueWatchingClick = onContinueWatchingClick,
|
navController.navigate(DetailRoute(type = item.type, id = item.id))
|
||||||
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
},
|
||||||
onSwitchProfile = onSwitchProfile,
|
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
|
||||||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
onContinueWatchingClick = onContinueWatchingClick,
|
||||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
onContinueWatchingLongPress = onContinueWatchingLongPress,
|
||||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
onSwitchProfile = onSwitchProfile,
|
||||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||||
onPluginsSettingsClick = {
|
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||||
if (AppFeaturePolicy.pluginsEnabled) {
|
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||||
navController.navigate(PluginsSettingsRoute)
|
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||||
}
|
onPluginsSettingsClick = {
|
||||||
},
|
if (AppFeaturePolicy.pluginsEnabled) {
|
||||||
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
navController.navigate(PluginsSettingsRoute)
|
||||||
onSupportersContributorsSettingsClick = {
|
}
|
||||||
navController.navigate(SupportersContributorsSettingsRoute)
|
},
|
||||||
},
|
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
|
||||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
onSupportersContributorsSettingsClick = {
|
||||||
{
|
navController.navigate(SupportersContributorsSettingsRoute)
|
||||||
appUpdaterController.checkForUpdates(
|
},
|
||||||
force = true,
|
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||||
showNoUpdateFeedback = true,
|
{
|
||||||
)
|
appUpdaterController.checkForUpdates(
|
||||||
}
|
force = true,
|
||||||
} else {
|
showNoUpdateFeedback = true,
|
||||||
null
|
)
|
||||||
},
|
}
|
||||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
} else {
|
||||||
onFolderClick = { collectionId, folderId ->
|
null
|
||||||
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
},
|
||||||
},
|
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
|
||||||
onInitialHomeContentRendered = { initialHomeReady = true },
|
onFolderClick = { collectionId, folderId ->
|
||||||
)
|
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
|
||||||
|
},
|
||||||
|
onInitialHomeContentRendered = { initialHomeReady = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isTabletLayout) {
|
if (isTabletLayout && !useNativeBottomTabs) {
|
||||||
TabletFloatingTopBar(
|
TabletFloatingTopBar(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { selectedTab = it },
|
onTabSelected = { selectedTab = it },
|
||||||
|
|
@ -1664,12 +1751,12 @@ private fun MainAppContent(
|
||||||
onToggleLibrary = {
|
onToggleLibrary = {
|
||||||
selectedPosterForActions?.let { preview ->
|
selectedPosterForActions?.let { preview ->
|
||||||
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
||||||
if (!isTraktConnected) {
|
if (!isTraktLibrarySource) {
|
||||||
LibraryRepository.toggleSaved(libraryItem)
|
LibraryRepository.toggleSaved(libraryItem)
|
||||||
} else {
|
} else {
|
||||||
pickerItem = libraryItem
|
pickerItem = libraryItem
|
||||||
pickerTitle = preview.name
|
pickerTitle = preview.name
|
||||||
pickerTabs = LibraryRepository.traktListTabs()
|
pickerTabs = LibraryRepository.libraryListTabs()
|
||||||
pickerMembership = pickerTabs.associate { it.key to false }
|
pickerMembership = pickerTabs.associate { it.key to false }
|
||||||
pickerPending = true
|
pickerPending = true
|
||||||
pickerError = null
|
pickerError = null
|
||||||
|
|
@ -1677,7 +1764,7 @@ private fun MainAppContent(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||||
val tabs = LibraryRepository.traktListTabs()
|
val tabs = LibraryRepository.libraryListTabs()
|
||||||
pickerTabs = tabs
|
pickerTabs = tabs
|
||||||
pickerMembership = tabs.associate { tab ->
|
pickerMembership = tabs.associate { tab ->
|
||||||
tab.key to (snapshot[tab.key] == true)
|
tab.key to (snapshot[tab.key] == true)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore
|
||||||
import com.nuvio.app.features.streams.StreamLaunchStore
|
import com.nuvio.app.features.streams.StreamLaunchStore
|
||||||
import com.nuvio.app.features.streams.StreamsRepository
|
import com.nuvio.app.features.streams.StreamsRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
|
|
@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner {
|
||||||
ThemeSettingsRepository.clearLocalState()
|
ThemeSettingsRepository.clearLocalState()
|
||||||
PosterCardStyleRepository.clearLocalState()
|
PosterCardStyleRepository.clearLocalState()
|
||||||
TraktAuthRepository.clearLocalState()
|
TraktAuthRepository.clearLocalState()
|
||||||
|
TraktSettingsRepository.clearLocalState()
|
||||||
PlayerSettingsRepository.clearLocalState()
|
PlayerSettingsRepository.clearLocalState()
|
||||||
CatalogRepository.clear()
|
CatalogRepository.clear()
|
||||||
StreamsRepository.clear()
|
StreamsRepository.clear()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import io.github.jan.supabase.postgrest.postgrest
|
import io.github.jan.supabase.postgrest.postgrest
|
||||||
|
|
@ -150,12 +152,14 @@ object ProfileSettingsSync {
|
||||||
val signatureFlows = listOf(
|
val signatureFlows = listOf(
|
||||||
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
||||||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||||
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||||
PlayerSettingsRepository.uiState.map { "player" },
|
PlayerSettingsRepository.uiState.map { "player" },
|
||||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||||
|
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||||
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
|
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
|
||||||
)
|
)
|
||||||
|
|
@ -199,6 +203,7 @@ object ProfileSettingsSync {
|
||||||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
||||||
|
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
||||||
notificationsSettings = NotificationsSettingsPayload(
|
notificationsSettings = NotificationsSettingsPayload(
|
||||||
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
|
episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled,
|
||||||
|
|
@ -230,6 +235,9 @@ object ProfileSettingsSync {
|
||||||
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
||||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||||
|
|
||||||
|
TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload)
|
||||||
|
TraktSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
|
TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings)
|
||||||
TraktCommentsSettings.onProfileChanged()
|
TraktCommentsSettings.onProfileChanged()
|
||||||
|
|
||||||
|
|
@ -244,6 +252,7 @@ object ProfileSettingsSync {
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MetaScreenSettingsRepository.ensureLoaded()
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
TraktCommentsSettings.ensureLoaded()
|
TraktCommentsSettings.ensureLoaded()
|
||||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||||
}
|
}
|
||||||
|
|
@ -257,12 +266,14 @@ object ProfileSettingsSync {
|
||||||
private fun currentObservedStateSignature(): String = listOf(
|
private fun currentObservedStateSignature(): String = listOf(
|
||||||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||||
|
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||||
"player=${PlayerSettingsRepository.uiState.value}",
|
"player=${PlayerSettingsRepository.uiState.value}",
|
||||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||||
|
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||||
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
||||||
).joinToString(separator = "||")
|
).joinToString(separator = "||")
|
||||||
|
|
@ -283,6 +294,7 @@ private data class MobileProfileSettingsFeatures(
|
||||||
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",
|
||||||
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
|
@SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "",
|
||||||
|
@SerialName("trakt_settings_payload") val traktSettingsPayload: String = "",
|
||||||
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
|
@SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
|
@ -12,10 +13,14 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
|
||||||
@Composable
|
@Composable
|
||||||
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
|
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
|
||||||
|
|
||||||
|
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
|
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
|
||||||
val navigationBarBottom = nuvioBottomNavigationBarInsets()
|
val navigationBarBottom = nuvioBottomNavigationBarInsets()
|
||||||
.asPaddingValues()
|
.asPaddingValues()
|
||||||
.calculateBottomPadding()
|
.calculateBottomPadding()
|
||||||
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) + extra
|
return navigationBarBottom.coerceAtLeast(nuvioPlatformExtraBottomPadding) +
|
||||||
|
LocalNuvioBottomNavigationOverlayPadding.current +
|
||||||
|
extra
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog
|
||||||
|
|
||||||
import com.nuvio.app.features.library.LibraryRepository
|
import com.nuvio.app.features.library.LibraryRepository
|
||||||
import com.nuvio.app.features.library.toMetaPreview
|
import com.nuvio.app.features.library.toMetaPreview
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
|
import com.nuvio.app.features.home.filterReleasedItems
|
||||||
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -124,7 +127,7 @@ object CatalogRepository {
|
||||||
catalogId = request.catalogId,
|
catalogId = request.catalogId,
|
||||||
genre = request.genre,
|
genre = request.genre,
|
||||||
skip = requestedSkip.takeIf { it > 0 },
|
skip = requestedSkip.takeIf { it > 0 },
|
||||||
)
|
).withUnreleasedFilter()
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { page ->
|
onSuccess = { page ->
|
||||||
if (activeRequest != request) return@fold
|
if (activeRequest != request) return@fold
|
||||||
|
|
@ -158,6 +161,12 @@ object CatalogRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||||
|
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||||
|
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||||
|
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||||
|
}
|
||||||
|
|
||||||
private data class CatalogRequest(
|
private data class CatalogRequest(
|
||||||
val manifestUrl: String,
|
val manifestUrl: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import com.nuvio.app.core.ui.posterCardClickable
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
import com.nuvio.app.features.home.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
@ -74,20 +75,21 @@ fun CatalogScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
|
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||||
CatalogRepository.load(
|
CatalogRepository.load(
|
||||||
manifestUrl = manifestUrl,
|
manifestUrl = manifestUrl,
|
||||||
type = type,
|
type = type,
|
||||||
catalogId = catalogId,
|
catalogId = catalogId,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
supportsPagination = supportsPagination,
|
supportsPagination = supportsPagination,
|
||||||
force = false,
|
force = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
|
@ -56,16 +57,13 @@ object CollectionSyncService {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val remoteJson = blob.collectionsJson.toString()
|
val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
|
||||||
val localJson = CollectionRepository.exportToJson()
|
JsonArray(emptyList())
|
||||||
|
} else {
|
||||||
if (remoteJson == "[]" || remoteJson == "null") {
|
blob.collectionsJson
|
||||||
val currentCollections = CollectionRepository.collections.value
|
|
||||||
if (currentCollections.isNotEmpty()) {
|
|
||||||
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val remoteJson = remoteCollectionsJson.toString()
|
||||||
|
val localJson = CollectionRepository.exportToJson()
|
||||||
|
|
||||||
if (remoteJson == localJson) {
|
if (remoteJson == localJson) {
|
||||||
log.d { "pullFromServer — remote matches local, no update needed" }
|
log.d { "pullFromServer — remote matches local, no update needed" }
|
||||||
|
|
@ -78,7 +76,7 @@ object CollectionSyncService {
|
||||||
|
|
||||||
if (remoteCollections != null) {
|
if (remoteCollections != null) {
|
||||||
isSyncingFromRemote = true
|
isSyncingFromRemote = true
|
||||||
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
|
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
|
||||||
isSyncingFromRemote = false
|
isSyncingFromRemote = false
|
||||||
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@ package com.nuvio.app.features.collection
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||||
|
import com.nuvio.app.features.catalog.CatalogPage
|
||||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||||
import com.nuvio.app.features.catalog.supportsPagination
|
import com.nuvio.app.features.catalog.supportsPagination
|
||||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.HomeCatalogSection
|
import com.nuvio.app.features.home.HomeCatalogSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
import com.nuvio.app.features.home.filterReleasedItems
|
||||||
import com.nuvio.app.features.home.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||||
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -320,7 +324,7 @@ object FolderDetailRepository {
|
||||||
genre = currentTab.genre,
|
genre = currentTab.genre,
|
||||||
skip = requestedSkip.takeIf { it > 0 },
|
skip = requestedSkip.takeIf { it > 0 },
|
||||||
)
|
)
|
||||||
}
|
}.withUnreleasedFilter()
|
||||||
}.onSuccess { page ->
|
}.onSuccess { page ->
|
||||||
updateTab(index) { tab ->
|
updateTab(index) { tab ->
|
||||||
val mergedItems = if (reset) {
|
val mergedItems = if (reset) {
|
||||||
|
|
@ -418,6 +422,12 @@ object FolderDetailRepository {
|
||||||
|
|
||||||
private fun Boolean?.orFalse(): Boolean = this == true
|
private fun Boolean?.orFalse(): Boolean = this == true
|
||||||
|
|
||||||
|
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||||
|
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||||
|
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||||
|
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||||
|
}
|
||||||
|
|
||||||
private fun tmdbCatalogId(source: CollectionSource): String =
|
private fun tmdbCatalogId(source: CollectionSource): String =
|
||||||
buildString {
|
buildString {
|
||||||
append("tmdb_")
|
append("tmdb_")
|
||||||
|
|
|
||||||
|
|
@ -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.AddonRepository
|
||||||
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
import com.nuvio.app.features.addons.buildAddonResourceUrl
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
|
import com.nuvio.app.features.home.filterReleasedItems
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
|
||||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||||
import com.nuvio.app.features.tmdb.TmdbService
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -48,14 +51,14 @@ object MetaDetailsRepository {
|
||||||
cachedEntry.metaScreenMeta
|
cachedEntry.metaScreenMeta
|
||||||
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
|
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
|
||||||
?.let { cachedMeta ->
|
?.let { cachedMeta ->
|
||||||
_uiState.value = MetaDetailsUiState(meta = cachedMeta)
|
_uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val cachedBaseMeta = cachedEntry.baseMeta
|
val cachedBaseMeta = cachedEntry.baseMeta
|
||||||
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
|
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
|
||||||
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta)
|
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +84,7 @@ object MetaDetailsRepository {
|
||||||
settingsFingerprint = metaScreenSettingsFingerprint,
|
settingsFingerprint = metaScreenSettingsFingerprint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -302,7 +305,7 @@ object MetaDetailsRepository {
|
||||||
cachedMetaByRequestKey[requestKey] = cachedEntry
|
cachedMetaByRequestKey[requestKey] = cachedEntry
|
||||||
|
|
||||||
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
|
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
|
||||||
_uiState.value = MetaDetailsUiState(meta = meta)
|
_uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -324,7 +327,7 @@ object MetaDetailsRepository {
|
||||||
metaScreenMeta = enrichedMeta,
|
metaScreenMeta = enrichedMeta,
|
||||||
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
|
||||||
)
|
)
|
||||||
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
|
_uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,6 +377,15 @@ object MetaDetailsRepository {
|
||||||
return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
|
return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
|
||||||
|
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||||
|
val todayIsoDate = CurrentDateProvider.todayIsoDate()
|
||||||
|
return copy(
|
||||||
|
moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
|
||||||
|
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
|
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
|
||||||
val meta = _uiState.value.meta ?: return emptyList()
|
val meta = _uiState.value.meta ?: return emptyList()
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import com.nuvio.app.features.library.LibraryRepository
|
||||||
import com.nuvio.app.features.library.toLibraryItem
|
import com.nuvio.app.features.library.toLibraryItem
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||||
|
|
@ -165,6 +166,7 @@ fun MetaDetailsScreen(
|
||||||
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||||
|
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
|
||||||
|
|
||||||
val shouldShowComments = commentsEnabled &&
|
val shouldShowComments = commentsEnabled &&
|
||||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||||
|
|
@ -192,6 +194,30 @@ fun MetaDetailsScreen(
|
||||||
isCommentsLoading = false
|
isCommentsLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
|
||||||
|
val metaForRatings = displayedMeta
|
||||||
|
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
|
||||||
|
episodeImdbRatings = emptyMap()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id)
|
||||||
|
val tmdbId = extractTmdbId(metaForRatings.id)
|
||||||
|
?: extractTmdbId(id)
|
||||||
|
?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull()
|
||||||
|
?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull()
|
||||||
|
|
||||||
|
if (imdbId == null && tmdbId == null) {
|
||||||
|
episodeImdbRatings = emptyMap()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings(
|
||||||
|
imdbId = imdbId,
|
||||||
|
tmdbId = tmdbId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
||||||
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
||||||
autoLoadAttempted = true
|
autoLoadAttempted = true
|
||||||
|
|
@ -274,39 +300,39 @@ fun MetaDetailsScreen(
|
||||||
val isSaved = remember(
|
val isSaved = remember(
|
||||||
libraryUiState.items,
|
libraryUiState.items,
|
||||||
libraryUiState.sections,
|
libraryUiState.sections,
|
||||||
traktAuthUiState.mode,
|
libraryUiState.sourceMode,
|
||||||
meta.id,
|
meta.id,
|
||||||
meta.type,
|
meta.type,
|
||||||
) {
|
) {
|
||||||
LibraryRepository.isSaved(meta.id, meta.type)
|
LibraryRepository.isSaved(meta.id, meta.type)
|
||||||
}
|
}
|
||||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
val openLibraryListPicker = remember(meta) {
|
||||||
val toggleSaved = remember(meta, isTraktConnected) {
|
|
||||||
{
|
{
|
||||||
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
|
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
|
||||||
if (!isTraktConnected) {
|
pickerTabs = LibraryRepository.libraryListTabs()
|
||||||
LibraryRepository.toggleSaved(libraryItem)
|
pickerMembership = pickerTabs.associate { it.key to false }
|
||||||
} else {
|
pickerPending = true
|
||||||
pickerTabs = LibraryRepository.traktListTabs()
|
pickerError = null
|
||||||
pickerMembership = pickerTabs.associate { it.key to false }
|
showLibraryListPicker = true
|
||||||
pickerPending = true
|
detailsScope.launch {
|
||||||
pickerError = null
|
runCatching {
|
||||||
showLibraryListPicker = true
|
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||||
detailsScope.launch {
|
val tabs = LibraryRepository.libraryListTabs()
|
||||||
runCatching {
|
pickerTabs = tabs
|
||||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
pickerMembership = tabs.associate { tab ->
|
||||||
val tabs = LibraryRepository.traktListTabs()
|
tab.key to (snapshot[tab.key] == true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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]
|
val movieProgress = watchProgressUiState.byVideoId[meta.id]
|
||||||
|
|
@ -637,6 +663,7 @@ fun MetaDetailsScreen(
|
||||||
onPrimaryPlayClick = onPrimaryPlayClick,
|
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||||
onSaveClick = toggleSaved,
|
onSaveClick = toggleSaved,
|
||||||
|
onSaveLongClick = openLibraryListPicker,
|
||||||
showManualPlayOption = showManualPlayOption,
|
showManualPlayOption = showManualPlayOption,
|
||||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||||
|
|
@ -653,6 +680,7 @@ fun MetaDetailsScreen(
|
||||||
commentsCurrentPage = commentsCurrentPage,
|
commentsCurrentPage = commentsCurrentPage,
|
||||||
commentsPageCount = commentsPageCount,
|
commentsPageCount = commentsPageCount,
|
||||||
commentsError = commentsError,
|
commentsError = commentsError,
|
||||||
|
episodeImdbRatings = episodeImdbRatings,
|
||||||
onRetryComments = {
|
onRetryComments = {
|
||||||
detailsScope.launch {
|
detailsScope.launch {
|
||||||
isCommentsLoading = true
|
isCommentsLoading = true
|
||||||
|
|
@ -687,6 +715,7 @@ fun MetaDetailsScreen(
|
||||||
onTrailerClick = resolveTrailer,
|
onTrailerClick = resolveTrailer,
|
||||||
progressByVideoId = watchProgressUiState.byVideoId,
|
progressByVideoId = watchProgressUiState.byVideoId,
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodePlayClick,
|
onEpisodeClick = onEpisodePlayClick,
|
||||||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||||
onOpenMeta = onOpenMeta,
|
onOpenMeta = onOpenMeta,
|
||||||
|
|
@ -933,6 +962,30 @@ fun MetaDetailsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
||||||
|
val normalizedType = type.trim().lowercase()
|
||||||
|
val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null }
|
||||||
|
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractImdbId(value: String?): String? =
|
||||||
|
value
|
||||||
|
?.trim()
|
||||||
|
?.split(':', '/', '?', '&')
|
||||||
|
?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
|
||||||
|
?.takeIf { it.length > 2 }
|
||||||
|
|
||||||
|
private fun extractTmdbId(value: String?): Int? {
|
||||||
|
val trimmed = value?.trim().orEmpty()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
return trimmed
|
||||||
|
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
|
||||||
|
?.substringAfter(':')
|
||||||
|
?.substringBefore(':')
|
||||||
|
?.substringBefore('/')
|
||||||
|
?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
private fun ConfiguredMetaSections(
|
private fun ConfiguredMetaSections(
|
||||||
|
|
@ -944,6 +997,7 @@ private fun ConfiguredMetaSections(
|
||||||
onPrimaryPlayClick: () -> Unit,
|
onPrimaryPlayClick: () -> Unit,
|
||||||
onPrimaryPlayLongClick: (() -> Unit)?,
|
onPrimaryPlayLongClick: (() -> Unit)?,
|
||||||
onSaveClick: () -> Unit,
|
onSaveClick: () -> Unit,
|
||||||
|
onSaveLongClick: (() -> Unit)?,
|
||||||
showManualPlayOption: Boolean,
|
showManualPlayOption: Boolean,
|
||||||
preferredEpisodeSeasonNumber: Int?,
|
preferredEpisodeSeasonNumber: Int?,
|
||||||
preferredEpisodeNumber: Int?,
|
preferredEpisodeNumber: Int?,
|
||||||
|
|
@ -960,12 +1014,14 @@ private fun ConfiguredMetaSections(
|
||||||
commentsCurrentPage: Int,
|
commentsCurrentPage: Int,
|
||||||
commentsPageCount: Int,
|
commentsPageCount: Int,
|
||||||
commentsError: String?,
|
commentsError: String?,
|
||||||
|
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||||
onRetryComments: () -> Unit,
|
onRetryComments: () -> Unit,
|
||||||
onLoadMoreComments: () -> Unit,
|
onLoadMoreComments: () -> Unit,
|
||||||
onCommentClick: (TraktCommentReview) -> Unit,
|
onCommentClick: (TraktCommentReview) -> Unit,
|
||||||
onTrailerClick: (MetaTrailer) -> Unit,
|
onTrailerClick: (MetaTrailer) -> Unit,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onEpisodeClick: (MetaVideo) -> Unit,
|
onEpisodeClick: (MetaVideo) -> Unit,
|
||||||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||||
|
|
@ -1008,6 +1064,7 @@ private fun ConfiguredMetaSections(
|
||||||
onPlayClick = onPrimaryPlayClick,
|
onPlayClick = onPrimaryPlayClick,
|
||||||
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
|
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
|
||||||
onSaveClick = onSaveClick,
|
onSaveClick = onSaveClick,
|
||||||
|
onSaveLongClick = onSaveLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MetaScreenSectionKey.OVERVIEW -> {
|
MetaScreenSectionKey.OVERVIEW -> {
|
||||||
|
|
@ -1057,6 +1114,8 @@ private fun ConfiguredMetaSections(
|
||||||
episodeCardStyle = settings.episodeCardStyle,
|
episodeCardStyle = settings.episodeCardStyle,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
episodeRatings = episodeImdbRatings,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
onEpisodeLongPress = onEpisodeLongPress,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
|
||||||
val cinematicBackground: Boolean = false,
|
val cinematicBackground: Boolean = false,
|
||||||
val tabLayout: Boolean = false,
|
val tabLayout: Boolean = false,
|
||||||
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
|
val blurUnwatchedEpisodes: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MetaEpisodeCardStyle {
|
enum class MetaEpisodeCardStyle {
|
||||||
|
|
@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
|
||||||
@SerialName("tvStyleLayout")
|
@SerialName("tvStyleLayout")
|
||||||
val tabLayout: Boolean = false,
|
val tabLayout: Boolean = false,
|
||||||
val episodeCardStyle: String = "horizontal",
|
val episodeCardStyle: String = "horizontal",
|
||||||
|
@SerialName("blur_unwatched_episodes")
|
||||||
|
val blurUnwatchedEpisodes: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MetaScreenSectionDefinition(
|
private data class MetaScreenSectionDefinition(
|
||||||
|
|
@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
|
||||||
private var cinematicBackground: Boolean = false
|
private var cinematicBackground: Boolean = false
|
||||||
private var tabLayout: Boolean = false
|
private var tabLayout: Boolean = false
|
||||||
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
private var blurUnwatchedEpisodes: Boolean = false
|
||||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
|
|
@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
|
||||||
tabLayout = parsed.tabLayout
|
tabLayout = parsed.tabLayout
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
|
||||||
?: MetaEpisodeCardStyle.Horizontal
|
?: MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
|
||||||
preferences = parsed.items.mapNotNull { item ->
|
preferences = parsed.items.mapNotNull { item ->
|
||||||
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
|
||||||
key to item
|
key to item
|
||||||
|
|
@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = false
|
cinematicBackground = false
|
||||||
tabLayout = false
|
tabLayout = false
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = false
|
||||||
_uiState.value = MetaScreenSettingsUiState()
|
_uiState.value = MetaScreenSettingsUiState()
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
blurUnwatchedEpisodes = enabled
|
||||||
|
publish()
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (!key.canBeTabbed) return
|
if (!key.canBeTabbed) return
|
||||||
|
|
@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
cinematicBackground = false
|
cinematicBackground = false
|
||||||
tabLayout = false
|
tabLayout = false
|
||||||
|
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = false
|
||||||
_uiState.value = MetaScreenSettingsUiState()
|
_uiState.value = MetaScreenSettingsUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground: Boolean,
|
cinematicBackground: Boolean,
|
||||||
tabLayout: Boolean,
|
tabLayout: Boolean,
|
||||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
) {
|
) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
this.cinematicBackground = cinematicBackground
|
this.cinematicBackground = cinematicBackground
|
||||||
this.tabLayout = tabLayout
|
this.tabLayout = tabLayout
|
||||||
this.episodeCardStyle = episodeCardStyle
|
this.episodeCardStyle = episodeCardStyle
|
||||||
|
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
|
||||||
preferences = items.associate { item ->
|
preferences = items.associate { item ->
|
||||||
item.key to StoredMetaScreenSectionPreference(
|
item.key to StoredMetaScreenSectionPreference(
|
||||||
key = item.key.name,
|
key = item.key.name,
|
||||||
|
|
@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = false
|
cinematicBackground = false
|
||||||
tabLayout = false
|
tabLayout = false
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
|
||||||
|
blurUnwatchedEpisodes = false
|
||||||
normalizePreferences()
|
normalizePreferences()
|
||||||
publish()
|
publish()
|
||||||
persist()
|
persist()
|
||||||
|
|
@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = cinematicBackground,
|
cinematicBackground = cinematicBackground,
|
||||||
tabLayout = tabLayout,
|
tabLayout = tabLayout,
|
||||||
episodeCardStyle = episodeCardStyle,
|
episodeCardStyle = episodeCardStyle,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
|
||||||
cinematicBackground = cinematicBackground,
|
cinematicBackground = cinematicBackground,
|
||||||
tabLayout = tabLayout,
|
tabLayout = tabLayout,
|
||||||
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.nuvio.app.features.details
|
||||||
|
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
|
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
|
|
@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
||||||
content = WatchingContentRef(type = type, id = id),
|
content = WatchingContentRef(type = type, id = id),
|
||||||
seasonNumber = season,
|
seasonNumber = season,
|
||||||
episodeNumber = episode,
|
episodeNumber = episode,
|
||||||
markedAtEpochMs = markedAtEpochMs,
|
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -44,6 +41,7 @@ fun DetailActionButtons(
|
||||||
onPlayClick: () -> Unit = {},
|
onPlayClick: () -> Unit = {},
|
||||||
onPlayLongClick: (() -> Unit)? = null,
|
onPlayLongClick: (() -> Unit)? = null,
|
||||||
onSaveClick: () -> Unit = {},
|
onSaveClick: () -> Unit = {},
|
||||||
|
onSaveLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
|
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
|
||||||
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
|
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
|
||||||
|
|
@ -96,35 +94,49 @@ fun DetailActionButtons(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
Surface(
|
||||||
onClick = onSaveClick,
|
|
||||||
modifier = rowButtonModifier.height(50.dp),
|
modifier = rowButtonModifier.height(50.dp),
|
||||||
shape = RoundedCornerShape(40.dp),
|
shape = RoundedCornerShape(40.dp),
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
if (isSaved) {
|
Row(
|
||||||
Icon(
|
modifier = Modifier
|
||||||
imageVector = Icons.Default.Check,
|
.fillMaxWidth()
|
||||||
contentDescription = null,
|
.combinedClickable(
|
||||||
modifier = Modifier.size(20.dp),
|
onClick = onSaveClick,
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
onLongClick = onSaveLongClick,
|
||||||
)
|
role = Role.Button,
|
||||||
} else {
|
)
|
||||||
Icon(
|
.height(50.dp),
|
||||||
painter = libraryAddPainter,
|
horizontalArrangement = Arrangement.Center,
|
||||||
contentDescription = null,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.size(18.dp),
|
) {
|
||||||
tint = 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -45,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -76,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val log = Logger.withTag("SeriesContent")
|
private val log = Logger.withTag("SeriesContent")
|
||||||
|
|
||||||
|
|
@ -90,6 +96,8 @@ fun DetailSeriesContent(
|
||||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||||
watchedKeys: Set<String> = emptySet(),
|
watchedKeys: Set<String> = emptySet(),
|
||||||
|
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
|
||||||
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
|
@ -276,6 +284,8 @@ fun DetailSeriesContent(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
|
episodeRatings = episodeRatings,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
onEpisodeLongPress = onEpisodeLongPress,
|
||||||
|
|
@ -295,13 +305,15 @@ fun DetailSeriesContent(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
metaType = meta.type,
|
metaType = meta.type,
|
||||||
metaId = meta.id,
|
metaId = meta.id,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
),
|
),
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
sizing = sizing,
|
sizing = sizing,
|
||||||
onClick = { onEpisodeClick?.invoke(episode) },
|
onClick = { onEpisodeClick?.invoke(episode) },
|
||||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||||
|
|
@ -553,6 +565,8 @@ private fun EpisodeHorizontalRow(
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
preferredEpisodeNumber: Int? = null,
|
preferredEpisodeNumber: Int? = null,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||||
|
|
@ -597,13 +611,15 @@ private fun EpisodeHorizontalRow(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = fallbackImage,
|
fallbackImage = fallbackImage,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
|
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
metaType = metaType,
|
metaType = metaType,
|
||||||
metaId = parentMetaId,
|
metaId = parentMetaId,
|
||||||
episode = episode,
|
episode = episode,
|
||||||
),
|
),
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
metrics = rowMetrics,
|
metrics = rowMetrics,
|
||||||
onClick = { onEpisodeClick?.invoke(episode) },
|
onClick = { onEpisodeClick?.invoke(episode) },
|
||||||
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
onLongPress = { onEpisodeLongPress?.invoke(episode) },
|
||||||
|
|
@ -618,12 +634,17 @@ private fun EpisodeHorizontalCard(
|
||||||
video: MetaVideo,
|
video: MetaVideo,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
|
imdbRating: Double?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
metrics: EpisodeHorizontalCardMetrics,
|
metrics: EpisodeHorizontalCardMetrics,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
||||||
|
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||||
|
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||||
|
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(metrics.cardWidth)
|
.width(metrics.cardWidth)
|
||||||
|
|
@ -642,11 +663,14 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val imageUrl = video.thumbnail ?: fallbackImage
|
val imageUrl = video.thumbnail ?: fallbackImage
|
||||||
|
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -666,30 +690,6 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
|
|
||||||
.clip(RoundedCornerShape(metrics.badgeRadius))
|
|
||||||
.background(Color.Black.copy(alpha = 0.75f))
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = Color.White.copy(alpha = 0.18f),
|
|
||||||
shape = RoundedCornerShape(metrics.badgeRadius),
|
|
||||||
)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = video.episodeBadge(),
|
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
|
||||||
fontSize = metrics.badgeTextSize,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
letterSpacing = 0.5.sp,
|
|
||||||
),
|
|
||||||
color = Color.White,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioAnimatedWatchedBadge(
|
NuvioAnimatedWatchedBadge(
|
||||||
isVisible = isWatched,
|
isVisible = isWatched,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -709,6 +709,15 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
|
EpisodeCodeBadge(
|
||||||
|
text = video.episodeBadge(),
|
||||||
|
textSize = metrics.badgeTextSize,
|
||||||
|
radius = metrics.badgeRadius,
|
||||||
|
horizontalPadding = metrics.badgeHorizontalPadding,
|
||||||
|
verticalPadding = metrics.badgeVerticalPadding,
|
||||||
|
backgroundAlpha = 0.42f,
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = video.title,
|
text = video.title,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
|
@ -734,27 +743,39 @@ private fun EpisodeHorizontalCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
) {
|
||||||
Text(
|
runtimeLabel?.let { runtime ->
|
||||||
text = formatEpisodeRuntime(runtimeMinutes),
|
Text(
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
text = runtime,
|
||||||
color = Color.White.copy(alpha = 0.78f),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||||
maxLines = 1,
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
)
|
maxLines = 1,
|
||||||
}
|
)
|
||||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
}
|
||||||
Text(
|
ratingLabel?.let { rating ->
|
||||||
text = formattedDate,
|
ImdbEpisodeRatingBadge(
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
rating = rating,
|
||||||
color = Color.White.copy(alpha = 0.78f),
|
logoWidth = metrics.imdbLogoWidth,
|
||||||
maxLines = 1,
|
logoHeight = metrics.imdbLogoHeight,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||||
val badgeRadius: Dp,
|
val badgeRadius: Dp,
|
||||||
|
val badgeHorizontalPadding: Dp,
|
||||||
|
val badgeVerticalPadding: Dp,
|
||||||
|
val imdbLogoWidth: Dp,
|
||||||
|
val imdbLogoHeight: Dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -815,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 3,
|
overviewMaxLines = 3,
|
||||||
metaTextSize = 12.sp,
|
metaTextSize = 12.sp,
|
||||||
badgeTextSize = 11.sp,
|
badgeTextSize = 11.sp,
|
||||||
badgeRadius = 6.dp,
|
badgeRadius = 8.dp,
|
||||||
|
badgeHorizontalPadding = 10.dp,
|
||||||
|
badgeVerticalPadding = 5.dp,
|
||||||
|
imdbLogoWidth = 28.dp,
|
||||||
|
imdbLogoHeight = 14.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -834,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 3,
|
overviewMaxLines = 3,
|
||||||
metaTextSize = 12.sp,
|
metaTextSize = 12.sp,
|
||||||
badgeTextSize = 10.sp,
|
badgeTextSize = 10.sp,
|
||||||
badgeRadius = 6.dp,
|
badgeRadius = 7.dp,
|
||||||
|
badgeHorizontalPadding = 9.dp,
|
||||||
|
badgeVerticalPadding = 4.dp,
|
||||||
|
imdbLogoWidth = 26.dp,
|
||||||
|
imdbLogoHeight = 13.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -853,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 2,
|
overviewMaxLines = 2,
|
||||||
metaTextSize = 11.sp,
|
metaTextSize = 11.sp,
|
||||||
badgeTextSize = 10.sp,
|
badgeTextSize = 10.sp,
|
||||||
badgeRadius = 5.dp,
|
badgeRadius = 6.dp,
|
||||||
|
badgeHorizontalPadding = 8.dp,
|
||||||
|
badgeVerticalPadding = 4.dp,
|
||||||
|
imdbLogoWidth = 24.dp,
|
||||||
|
imdbLogoHeight = 12.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> EpisodeHorizontalCardMetrics(
|
else -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -873,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
metaTextSize = 10.sp,
|
metaTextSize = 10.sp,
|
||||||
badgeTextSize = 9.sp,
|
badgeTextSize = 9.sp,
|
||||||
badgeRadius = 5.dp,
|
badgeRadius = 5.dp,
|
||||||
|
badgeHorizontalPadding = 7.dp,
|
||||||
|
badgeVerticalPadding = 3.dp,
|
||||||
|
imdbLogoWidth = 22.dp,
|
||||||
|
imdbLogoHeight = 11.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -882,19 +923,83 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
|
||||||
return formatRuntimeFromMinutes(runtimeMinutes)
|
return formatRuntimeFromMinutes(runtimeMinutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeCodeBadge(
|
||||||
|
text: String,
|
||||||
|
textSize: androidx.compose.ui.unit.TextUnit,
|
||||||
|
radius: Dp,
|
||||||
|
horizontalPadding: Dp,
|
||||||
|
verticalPadding: Dp,
|
||||||
|
backgroundAlpha: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(radius))
|
||||||
|
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImdbEpisodeRatingBadge(
|
||||||
|
rating: String,
|
||||||
|
logoWidth: Dp,
|
||||||
|
logoHeight: Dp,
|
||||||
|
textSize: androidx.compose.ui.unit.TextUnit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(Res.drawable.rating_imdb),
|
||||||
|
contentDescription = stringResource(Res.string.source_imdb),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(logoWidth)
|
||||||
|
.height(logoHeight),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = rating,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = Color(0xFFF5C518),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeListCard(
|
private fun EpisodeListCard(
|
||||||
video: MetaVideo,
|
video: MetaVideo,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
|
imdbRating: Double?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
||||||
|
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||||
|
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -923,11 +1028,14 @@ private fun EpisodeListCard(
|
||||||
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
|
||||||
) {
|
) {
|
||||||
val imageUrl = video.thumbnail ?: fallbackImage
|
val imageUrl = video.thumbnail ?: fallbackImage
|
||||||
|
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = video.title,
|
contentDescription = video.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -938,32 +1046,17 @@ private fun EpisodeListCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
EpisodeCodeBadge(
|
||||||
|
text = video.episodeBadge(),
|
||||||
|
textSize = sizing.badgeTextSize,
|
||||||
|
radius = sizing.badgeRadius,
|
||||||
|
horizontalPadding = sizing.badgeHorizontalPadding,
|
||||||
|
verticalPadding = sizing.badgeVerticalPadding,
|
||||||
|
backgroundAlpha = 0.85f,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(start = 8.dp, top = 8.dp)
|
.padding(start = 8.dp, top = 8.dp),
|
||||||
.clip(RoundedCornerShape(sizing.badgeRadius))
|
)
|
||||||
.background(Color.Black.copy(alpha = 0.85f))
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = Color.White.copy(alpha = 0.2f),
|
|
||||||
shape = RoundedCornerShape(sizing.badgeRadius),
|
|
||||||
)
|
|
||||||
.padding(
|
|
||||||
horizontal = sizing.badgeHorizontalPadding,
|
|
||||||
vertical = sizing.badgeVerticalPadding,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = video.episodeBadge(),
|
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
|
||||||
fontSize = sizing.badgeTextSize,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
letterSpacing = 0.3.sp,
|
|
||||||
),
|
|
||||||
color = Color.White,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioAnimatedWatchedBadge(
|
NuvioAnimatedWatchedBadge(
|
||||||
isVisible = isWatched,
|
isVisible = isWatched,
|
||||||
|
|
@ -991,24 +1084,39 @@ private fun EpisodeListCard(
|
||||||
fontSize = sizing.titleTextSize,
|
fontSize = sizing.titleTextSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = sizing.titleLineHeight,
|
lineHeight = sizing.titleLineHeight,
|
||||||
letterSpacing = 0.3.sp,
|
letterSpacing = 0.sp,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = sizing.titleMaxLines,
|
maxLines = sizing.titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
if (formattedDate != null || ratingLabel != null) {
|
||||||
Text(
|
Row(
|
||||||
text = formattedDate,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontSize = sizing.metaTextSize,
|
) {
|
||||||
fontWeight = FontWeight.Medium,
|
formattedDate?.let { date ->
|
||||||
),
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
text = date,
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
overflow = TextOverflow.Ellipsis,
|
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()) {
|
if (!video.overview.isNullOrBlank()) {
|
||||||
|
|
@ -1211,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String =
|
||||||
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||||
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {
|
||||||
|
val seasonNumber = season ?: return null
|
||||||
|
val episodeNumber = episode ?: return null
|
||||||
|
return seasonNumber to episodeNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatEpisodeRating(rating: Double): String {
|
||||||
|
val roundedTenths = (rating * 10.0).roundToInt()
|
||||||
|
val whole = roundedTenths / 10
|
||||||
|
val tenth = (roundedTenths % 10).absoluteValue
|
||||||
|
return "$whole.$tenth"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
val completedEpisodes = remember(uiState.items) {
|
||||||
uiState.completedItems
|
uiState.completedItems
|
||||||
.filter { it.isEpisode }
|
.filter { it.isEpisode }
|
||||||
.sortedByDescending { it.updatedAtEpochMs }
|
.sortedForSeriesDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
||||||
|
|
@ -229,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
|
||||||
) {
|
) {
|
||||||
val showEpisodes = episodes
|
val showEpisodes = episodes
|
||||||
.filter { it.parentMetaId == showId }
|
.filter { it.parentMetaId == showId }
|
||||||
|
.sortedForSeriesDownloads()
|
||||||
|
|
||||||
val seasons = showEpisodes
|
val seasons = showEpisodes
|
||||||
.groupBy { it.seasonNumber ?: 0 }
|
.groupBy { it.seasonNumber ?: 0 }
|
||||||
|
|
@ -268,10 +269,7 @@ private fun LazyListScope.downloadsShowContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortedEpisodes = entries.sortedWith(
|
val sortedEpisodes = entries.sortedForSeriesDownloads()
|
||||||
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
|
|
||||||
.thenByDescending { it.updatedAtEpochMs },
|
|
||||||
)
|
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = sortedEpisodes,
|
items = sortedEpisodes,
|
||||||
|
|
@ -298,6 +296,12 @@ private fun DownloadRow(
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val displayTitle = item.displayTitle()
|
||||||
|
val displaySubtitle = downloadDisplaySubtitle(
|
||||||
|
item = item,
|
||||||
|
displayTitle = displayTitle,
|
||||||
|
)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -322,7 +326,7 @@ private fun DownloadRow(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item.title,
|
text = displayTitle,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -330,7 +334,7 @@ private fun DownloadRow(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.displaySubtitle,
|
text = displaySubtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -403,6 +407,36 @@ private fun DownloadRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun DownloadItem.displayTitle(): String =
|
||||||
|
if (isEpisode) {
|
||||||
|
episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: title
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun downloadDisplaySubtitle(
|
||||||
|
item: DownloadItem,
|
||||||
|
displayTitle: String,
|
||||||
|
): String {
|
||||||
|
val seasonNumber = item.seasonNumber
|
||||||
|
val episodeNumber = item.episodeNumber
|
||||||
|
if (seasonNumber == null || episodeNumber == null) {
|
||||||
|
return item.displaySubtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
val episodeCode = stringResource(
|
||||||
|
Res.string.compose_player_episode_code_full,
|
||||||
|
seasonNumber,
|
||||||
|
episodeNumber,
|
||||||
|
)
|
||||||
|
return listOf(
|
||||||
|
episodeCode,
|
||||||
|
item.episodeTitle?.trim().orEmpty().takeIf { it.isNotBlank() && it != displayTitle },
|
||||||
|
item.title.trim().takeIf { it.isNotBlank() && it != displayTitle },
|
||||||
|
).filterNotNull().joinToString(" • ")
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionTitle(title: String) {
|
private fun SectionTitle(title: String) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ internal object HomeCatalogParser {
|
||||||
posterShape = meta.string("posterShape").toPosterShape(),
|
posterShape = meta.string("posterShape").toPosterShape(),
|
||||||
description = meta.string("description"),
|
description = meta.string("description"),
|
||||||
releaseInfo = meta.string("releaseInfo"),
|
releaseInfo = meta.string("releaseInfo"),
|
||||||
|
rawReleaseDate = meta.string("released"),
|
||||||
imdbRating = meta.string("imdbRating"),
|
imdbRating = meta.string("imdbRating"),
|
||||||
genres = meta.array("genres").mapNotNull { genre ->
|
genres = meta.array("genres").mapNotNull { genre ->
|
||||||
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }
|
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem(
|
||||||
|
|
||||||
data class HomeCatalogSettingsUiState(
|
data class HomeCatalogSettingsUiState(
|
||||||
val heroEnabled: Boolean = true,
|
val heroEnabled: Boolean = true,
|
||||||
|
val hideUnreleasedContent: Boolean = false,
|
||||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val signature: String
|
val signature: String
|
||||||
get() = buildString {
|
get() = buildString {
|
||||||
append(heroEnabled)
|
append(heroEnabled)
|
||||||
append('|')
|
append('|')
|
||||||
|
append(hideUnreleasedContent)
|
||||||
|
append('|')
|
||||||
append(
|
append(
|
||||||
items.joinToString(separator = "|") { item ->
|
items.joinToString(separator = "|") { item ->
|
||||||
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
|
||||||
|
|
@ -55,6 +58,7 @@ internal data class HomeCatalogPreference(
|
||||||
|
|
||||||
internal data class HomeCatalogSettingsSnapshot(
|
internal data class HomeCatalogSettingsSnapshot(
|
||||||
val heroEnabled: Boolean,
|
val heroEnabled: Boolean,
|
||||||
|
val hideUnreleasedContent: Boolean,
|
||||||
val preferences: Map<String, HomeCatalogPreference>,
|
val preferences: Map<String, HomeCatalogPreference>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference(
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class StoredHomeCatalogSettingsPayload(
|
private data class StoredHomeCatalogSettingsPayload(
|
||||||
val heroEnabled: Boolean = true,
|
val heroEnabled: Boolean = true,
|
||||||
|
val hideUnreleasedContent: Boolean = false,
|
||||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository {
|
||||||
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
|
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
|
||||||
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
||||||
private var heroEnabled = true
|
private var heroEnabled = true
|
||||||
|
private var hideUnreleasedContent = false
|
||||||
|
|
||||||
fun onProfileChanged() {
|
fun onProfileChanged() {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
|
hideUnreleasedContent = false
|
||||||
definitions = emptyList()
|
definitions = emptyList()
|
||||||
collectionDefinitions = emptyList()
|
collectionDefinitions = emptyList()
|
||||||
_uiState.value = HomeCatalogSettingsUiState()
|
_uiState.value = HomeCatalogSettingsUiState()
|
||||||
|
|
@ -105,6 +112,7 @@ object HomeCatalogSettingsRepository {
|
||||||
collectionDefinitions = emptyList()
|
collectionDefinitions = emptyList()
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
|
hideUnreleasedContent = false
|
||||||
_uiState.value = HomeCatalogSettingsUiState()
|
_uiState.value = HomeCatalogSettingsUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,6 +143,7 @@ object HomeCatalogSettingsRepository {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
return HomeCatalogSettingsSnapshot(
|
return HomeCatalogSettingsSnapshot(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
preferences = preferences.mapValues { (_, value) ->
|
preferences = preferences.mapValues { (_, value) ->
|
||||||
HomeCatalogPreference(
|
HomeCatalogPreference(
|
||||||
customTitle = value.customTitle,
|
customTitle = value.customTitle,
|
||||||
|
|
@ -154,6 +163,15 @@ object HomeCatalogSettingsRepository {
|
||||||
HomeRepository.applyCurrentSettings()
|
HomeRepository.applyCurrentSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHideUnreleasedContent(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (hideUnreleasedContent == enabled) return
|
||||||
|
hideUnreleasedContent = enabled
|
||||||
|
publish()
|
||||||
|
persist()
|
||||||
|
HomeRepository.applyCurrentSettings()
|
||||||
|
}
|
||||||
|
|
||||||
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
||||||
updatePreference(key) { preference ->
|
updatePreference(key) { preference ->
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|
@ -181,6 +199,7 @@ object HomeCatalogSettingsRepository {
|
||||||
fun resetToDefaults() {
|
fun resetToDefaults() {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
|
hideUnreleasedContent = false
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
normalizePreferences()
|
normalizePreferences()
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -226,7 +245,9 @@ object HomeCatalogSettingsRepository {
|
||||||
|
|
||||||
if (parsedPayload != null) {
|
if (parsedPayload != null) {
|
||||||
heroEnabled = parsedPayload.heroEnabled
|
heroEnabled = parsedPayload.heroEnabled
|
||||||
|
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||||
|
publish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,6 +256,7 @@ object HomeCatalogSettingsRepository {
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
preferences = legacyItems.associateBy { it.key }.toMutableMap()
|
preferences = legacyItems.associateBy { it.key }.toMutableMap()
|
||||||
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizePreferences() {
|
private fun normalizePreferences() {
|
||||||
|
|
@ -322,6 +344,7 @@ object HomeCatalogSettingsRepository {
|
||||||
|
|
||||||
_uiState.value = HomeCatalogSettingsUiState(
|
_uiState.value = HomeCatalogSettingsUiState(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
items = items,
|
items = items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +354,7 @@ object HomeCatalogSettingsRepository {
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
StoredHomeCatalogSettingsPayload(
|
StoredHomeCatalogSettingsPayload(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
items = preferences.values.sortedBy { it.order },
|
items = preferences.values.sortedBy { it.order },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -411,26 +435,32 @@ object HomeCatalogSettingsRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return SyncHomeCatalogPayload(items = items)
|
return SyncHomeCatalogPayload(
|
||||||
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
items = items,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||||
preferences = payload.items.associate { item ->
|
if (payload.items.isNotEmpty()) {
|
||||||
val key = if (item.isCollection) {
|
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||||
"collection_${item.collectionId}"
|
preferences = payload.items.associate { item ->
|
||||||
} else {
|
val key = if (item.isCollection) {
|
||||||
"${item.addonId}:${item.type}:${item.catalogId}"
|
"collection_${item.collectionId}"
|
||||||
}
|
} else {
|
||||||
key to StoredHomeCatalogPreference(
|
"${item.addonId}:${item.type}:${item.catalogId}"
|
||||||
key = key,
|
}
|
||||||
customTitle = item.customTitle,
|
key to StoredHomeCatalogPreference(
|
||||||
enabled = item.enabled,
|
key = key,
|
||||||
heroSourceEnabled = existingHeroState[key] ?: true,
|
customTitle = item.customTitle,
|
||||||
order = item.order,
|
enabled = item.enabled,
|
||||||
)
|
heroSourceEnabled = existingHeroState[key] ?: true,
|
||||||
}.toMutableMap()
|
order = item.order,
|
||||||
|
)
|
||||||
|
}.toMutableMap()
|
||||||
|
}
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
publish()
|
publish()
|
||||||
persist()
|
persist()
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ data class SyncCatalogItem(
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncHomeCatalogPayload(
|
data class SyncHomeCatalogPayload(
|
||||||
|
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||||
val items: List<SyncCatalogItem> = emptyList(),
|
val items: List<SyncCatalogItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remotePayload.items.isEmpty()) {
|
if (remotePayload.items.isEmpty()) {
|
||||||
log.i { "pullFromServer — remote has empty items, preserving local" }
|
log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
|
||||||
|
isSyncingFromRemote = true
|
||||||
|
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
|
||||||
|
isSyncingFromRemote = false
|
||||||
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
|
||||||
if (localPayload.items.isNotEmpty()) {
|
if (localPayload.items.isNotEmpty()) {
|
||||||
pushToRemote(profileId)
|
pushToRemote(profileId)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.home
|
||||||
|
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||||
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -145,13 +146,17 @@ object HomeRepository {
|
||||||
) {
|
) {
|
||||||
val snapshot = HomeCatalogSettingsRepository.snapshot()
|
val snapshot = HomeCatalogSettingsRepository.snapshot()
|
||||||
val preferences = snapshot.preferences
|
val preferences = snapshot.preferences
|
||||||
|
val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null
|
||||||
|
fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection =
|
||||||
|
if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate)
|
||||||
|
|
||||||
val sections = currentDefinitions
|
val sections = currentDefinitions
|
||||||
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
|
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
|
||||||
.mapNotNull { definition ->
|
.mapNotNull { definition ->
|
||||||
val preference = preferences[definition.key]
|
val preference = preferences[definition.key]
|
||||||
if (preference?.enabled == false) return@mapNotNull null
|
if (preference?.enabled == false) return@mapNotNull null
|
||||||
|
|
||||||
val section = cachedSections[definition.key] ?: return@mapNotNull null
|
val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null
|
||||||
if (section.items.isEmpty()) return@mapNotNull null
|
if (section.items.isEmpty()) return@mapNotNull null
|
||||||
val customTitle = preference?.customTitle.orEmpty()
|
val customTitle = preference?.customTitle.orEmpty()
|
||||||
section.copy(
|
section.copy(
|
||||||
|
|
@ -164,6 +169,7 @@ object HomeRepository {
|
||||||
currentDefinitions
|
currentDefinitions
|
||||||
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
|
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
|
||||||
.mapNotNull { definition -> cachedSections[definition.key] }
|
.mapNotNull { definition -> cachedSections[definition.key] }
|
||||||
|
.map { section -> section.withReleaseFilter() }
|
||||||
.flatMap { section -> section.items }
|
.flatMap { section -> section.items }
|
||||||
.distinctBy { item -> "${item.type}:${item.id}" }
|
.distinctBy { item -> "${item.type}:${item.id}" }
|
||||||
.shuffled(heroRandom)
|
.shuffled(heroRandom)
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.network.NetworkCondition
|
import com.nuvio.app.core.network.NetworkCondition
|
||||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||||
|
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
import com.nuvio.app.features.details.nextReleasedEpisodeAfter
|
||||||
|
|
@ -29,6 +31,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection
|
||||||
import com.nuvio.app.features.home.components.HomeSkeletonHero
|
import com.nuvio.app.features.home.components.HomeSkeletonHero
|
||||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
|
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
|
||||||
|
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watchprogress.CachedInProgressItem
|
import com.nuvio.app.features.watchprogress.CachedInProgressItem
|
||||||
import com.nuvio.app.features.watchprogress.CachedNextUpItem
|
import com.nuvio.app.features.watchprogress.CachedNextUpItem
|
||||||
|
|
@ -36,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
|
||||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
|
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
||||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
|
@ -45,6 +52,7 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
|
||||||
import com.nuvio.app.features.watching.application.WatchingState
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
|
import com.nuvio.app.features.watching.domain.isReleasedBy
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
import com.nuvio.app.features.home.components.HomeCollectionRowSection
|
||||||
|
|
@ -87,6 +95,10 @@ fun HomeScreen(
|
||||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val traktSettingsUiState by remember {
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val isTraktAuthenticated by remember {
|
val isTraktAuthenticated by remember {
|
||||||
TraktAuthRepository.ensureLoaded()
|
TraktAuthRepository.ensureLoaded()
|
||||||
TraktAuthRepository.isAuthenticated
|
TraktAuthRepository.isAuthenticated
|
||||||
|
|
@ -114,17 +126,31 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) {
|
val isTraktProgressActive = remember(
|
||||||
if (!isTraktAuthenticated) {
|
isTraktAuthenticated,
|
||||||
watchProgressUiState.entries
|
traktSettingsUiState.watchProgressSource,
|
||||||
} else {
|
) {
|
||||||
val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L)
|
shouldUseTraktProgress(
|
||||||
watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
isAuthenticated = isTraktAuthenticated,
|
||||||
}
|
source = traktSettingsUiState.watchProgressSource,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) {
|
val effectiveWatchProgressEntries = remember(
|
||||||
if (isTraktAuthenticated) emptyList() else watchedUiState.items
|
watchProgressUiState.entries,
|
||||||
|
isTraktProgressActive,
|
||||||
|
traktSettingsUiState.continueWatchingDaysCap,
|
||||||
|
) {
|
||||||
|
filterEntriesForTraktContinueWatchingWindow(
|
||||||
|
entries = watchProgressUiState.entries,
|
||||||
|
isTraktProgressActive = isTraktProgressActive,
|
||||||
|
daysCap = traktSettingsUiState.continueWatchingDaysCap,
|
||||||
|
nowEpochMs = WatchProgressClock.nowEpochMs(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) {
|
||||||
|
if (isTraktProgressActive) emptyList() else watchedUiState.items
|
||||||
}
|
}
|
||||||
|
|
||||||
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
|
val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) {
|
||||||
|
|
@ -144,6 +170,9 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val completedSeriesContentIds = remember(completedSeriesCandidates) {
|
||||||
|
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
|
||||||
|
}
|
||||||
val visibleContinueWatchingEntries = remember(
|
val visibleContinueWatchingEntries = remember(
|
||||||
effectiveWatchProgressEntries,
|
effectiveWatchProgressEntries,
|
||||||
latestCompletedBySeries,
|
latestCompletedBySeries,
|
||||||
|
|
@ -159,11 +188,28 @@ fun HomeScreen(
|
||||||
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||||
|
|
||||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||||
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) {
|
val cachedNextUpItems = remember(
|
||||||
|
cachedSnapshots.first,
|
||||||
|
continueWatchingPreferences.dismissedNextUpKeys,
|
||||||
|
completedSeriesContentIds,
|
||||||
|
isTraktProgressActive,
|
||||||
|
continueWatchingPreferences.showUnairedNextUp,
|
||||||
|
watchedUiState.isLoaded,
|
||||||
|
) {
|
||||||
cachedSnapshots.first.mapNotNull { cached ->
|
cachedSnapshots.first.mapNotNull { cached ->
|
||||||
|
if (
|
||||||
|
!isTraktProgressActive &&
|
||||||
|
watchedUiState.isLoaded &&
|
||||||
|
cached.contentId !in completedSeriesContentIds
|
||||||
|
) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
|
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
|
||||||
cached.contentId to (cached.sortTimestamp to item)
|
cached.contentId to (cached.sortTimestamp to item)
|
||||||
}.toMap()
|
}.toMap()
|
||||||
|
|
@ -242,7 +288,11 @@ fun HomeScreen(
|
||||||
HomeCatalogSettingsRepository.syncCollections(collections)
|
HomeCatalogSettingsRepository.syncCollections(collections)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(completedSeriesCandidates, metaProviderKey) {
|
LaunchedEffect(
|
||||||
|
completedSeriesCandidates,
|
||||||
|
metaProviderKey,
|
||||||
|
continueWatchingPreferences.showUnairedNextUp,
|
||||||
|
) {
|
||||||
if (completedSeriesCandidates.isEmpty()) {
|
if (completedSeriesCandidates.isEmpty()) {
|
||||||
nextUpItemsBySeries = emptyMap()
|
nextUpItemsBySeries = emptyMap()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
|
|
@ -263,7 +313,7 @@ fun HomeScreen(
|
||||||
seasonNumber = completedEntry.seasonNumber,
|
seasonNumber = completedEntry.seasonNumber,
|
||||||
episodeNumber = completedEntry.episodeNumber,
|
episodeNumber = completedEntry.episodeNumber,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
showUnairedNextUp = isTraktAuthenticated,
|
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
|
||||||
) ?: return@withPermit null
|
) ?: return@withPermit null
|
||||||
val item = completedEntry.toContinueWatchingSeed(meta)
|
val item = completedEntry.toContinueWatchingSeed(meta)
|
||||||
.toUpNextContinueWatchingItem(nextEpisode)
|
.toUpNextContinueWatchingItem(nextEpisode)
|
||||||
|
|
@ -291,6 +341,10 @@ fun HomeScreen(
|
||||||
episodeTitle = item.episodeTitle,
|
episodeTitle = item.episodeTitle,
|
||||||
episodeThumbnail = item.episodeThumbnail,
|
episodeThumbnail = item.episodeThumbnail,
|
||||||
pauseDescription = item.pauseDescription,
|
pauseDescription = item.pauseDescription,
|
||||||
|
released = item.released,
|
||||||
|
hasAired = item.released?.let { released ->
|
||||||
|
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
|
||||||
|
} ?: true,
|
||||||
lastWatched = pair.first,
|
lastWatched = pair.first,
|
||||||
sortTimestamp = pair.first,
|
sortTimestamp = pair.first,
|
||||||
seedSeason = item.nextUpSeedSeasonNumber,
|
seedSeason = item.nextUpSeedSeasonNumber,
|
||||||
|
|
@ -353,12 +407,19 @@ fun HomeScreen(
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||||
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
|
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
|
||||||
|
val nativeBottomNavigationOverlayHeight =
|
||||||
|
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
|
||||||
|
nuvioSafeBottomPadding()
|
||||||
|
} else {
|
||||||
|
0.dp
|
||||||
|
}
|
||||||
val mobileHeroBelowSectionHeightHint = remember(
|
val mobileHeroBelowSectionHeightHint = remember(
|
||||||
maxWidth.value,
|
maxWidth.value,
|
||||||
continueWatchingPreferences.isVisible,
|
continueWatchingPreferences.isVisible,
|
||||||
continueWatchingPreferences.style,
|
continueWatchingPreferences.style,
|
||||||
continueWatchingItems.isNotEmpty(),
|
continueWatchingItems.isNotEmpty(),
|
||||||
continueWatchingLayout,
|
continueWatchingLayout,
|
||||||
|
nativeBottomNavigationOverlayHeight,
|
||||||
) {
|
) {
|
||||||
heroMobileBelowSectionHeightHint(
|
heroMobileBelowSectionHeightHint(
|
||||||
maxWidthDp = maxWidth.value,
|
maxWidthDp = maxWidth.value,
|
||||||
|
|
@ -366,6 +427,7 @@ fun HomeScreen(
|
||||||
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
|
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
|
||||||
continueWatchingStyle = continueWatchingPreferences.style,
|
continueWatchingStyle = continueWatchingPreferences.style,
|
||||||
continueWatchingLayout = continueWatchingLayout,
|
continueWatchingLayout = continueWatchingLayout,
|
||||||
|
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,6 +471,8 @@ fun HomeScreen(
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
items = continueWatchingItems,
|
items = continueWatchingItems,
|
||||||
style = continueWatchingPreferences.style,
|
style = continueWatchingPreferences.style,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||||
|
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
layout = continueWatchingLayout,
|
layout = continueWatchingLayout,
|
||||||
|
|
@ -432,6 +496,8 @@ fun HomeScreen(
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
items = continueWatchingItems,
|
items = continueWatchingItems,
|
||||||
style = continueWatchingPreferences.style,
|
style = continueWatchingPreferences.style,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||||
|
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
layout = continueWatchingLayout,
|
layout = continueWatchingLayout,
|
||||||
|
|
@ -474,6 +540,8 @@ fun HomeScreen(
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
items = continueWatchingItems,
|
items = continueWatchingItems,
|
||||||
style = continueWatchingPreferences.style,
|
style = continueWatchingPreferences.style,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
|
||||||
|
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
layout = continueWatchingLayout,
|
layout = continueWatchingLayout,
|
||||||
|
|
@ -525,7 +593,21 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
|
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
|
||||||
private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
|
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||||
|
|
||||||
|
internal fun filterEntriesForTraktContinueWatchingWindow(
|
||||||
|
entries: List<WatchProgressEntry>,
|
||||||
|
isTraktProgressActive: Boolean,
|
||||||
|
daysCap: Int,
|
||||||
|
nowEpochMs: Long,
|
||||||
|
): List<WatchProgressEntry> {
|
||||||
|
if (!isTraktProgressActive) return entries
|
||||||
|
val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap)
|
||||||
|
if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries
|
||||||
|
|
||||||
|
val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY)
|
||||||
|
return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs }
|
||||||
|
}
|
||||||
|
|
||||||
private fun heroMobileBelowSectionHeightHint(
|
private fun heroMobileBelowSectionHeightHint(
|
||||||
maxWidthDp: Float,
|
maxWidthDp: Float,
|
||||||
|
|
@ -533,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint(
|
||||||
hasContinueWatchingItems: Boolean,
|
hasContinueWatchingItems: Boolean,
|
||||||
continueWatchingStyle: ContinueWatchingSectionStyle,
|
continueWatchingStyle: ContinueWatchingSectionStyle,
|
||||||
continueWatchingLayout: ContinueWatchingLayout,
|
continueWatchingLayout: ContinueWatchingLayout,
|
||||||
|
bottomNavigationOverlayHeight: Dp,
|
||||||
): Dp? {
|
): Dp? {
|
||||||
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
|
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
|
||||||
|
|
||||||
return when (continueWatchingStyle) {
|
val sectionHeight = when (continueWatchingStyle) {
|
||||||
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
|
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
|
||||||
ContinueWatchingSectionStyle.Poster ->
|
ContinueWatchingSectionStyle.Poster ->
|
||||||
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
|
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
|
||||||
}
|
}
|
||||||
|
return sectionHeight + bottomNavigationOverlayHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun buildHomeContinueWatchingItems(
|
internal fun buildHomeContinueWatchingItems(
|
||||||
|
|
@ -548,6 +632,13 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||||
): List<ContinueWatchingItem> {
|
): List<ContinueWatchingItem> {
|
||||||
|
val inProgressSeriesIds = visibleEntries
|
||||||
|
.asSequence()
|
||||||
|
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||||
|
.map { entry -> entry.parentMetaId }
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.toSet()
|
||||||
|
|
||||||
return buildList {
|
return buildList {
|
||||||
addAll(
|
addAll(
|
||||||
visibleEntries.map { entry ->
|
visibleEntries.map { entry ->
|
||||||
|
|
@ -560,7 +651,8 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
addAll(
|
addAll(
|
||||||
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
|
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
|
||||||
|
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
|
||||||
HomeContinueWatchingCandidate(
|
HomeContinueWatchingCandidate(
|
||||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -574,7 +666,7 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
.thenByDescending { it.isProgressEntry },
|
.thenByDescending { it.isProgressEntry },
|
||||||
)
|
)
|
||||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||||
.distinctBy { it.item.videoId }
|
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
||||||
.map(HomeContinueWatchingCandidate::item)
|
.map(HomeContinueWatchingCandidate::item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
episodeThumbnail = episodeThumbnail,
|
episodeThumbnail = episodeThumbnail,
|
||||||
pauseDescription = pauseDescription,
|
pauseDescription = pauseDescription,
|
||||||
|
released = released,
|
||||||
isNextUp = true,
|
isNextUp = true,
|
||||||
nextUpSeedSeasonNumber = seedSeason,
|
nextUpSeedSeasonNumber = seedSeason,
|
||||||
nextUpSeedEpisodeNumber = seedEpisode,
|
nextUpSeedEpisodeNumber = seedEpisode,
|
||||||
|
|
@ -698,5 +791,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
|
||||||
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
episodeTitle = episodeTitle ?: fallback.episodeTitle,
|
||||||
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
|
||||||
pauseDescription = pauseDescription ?: fallback.pauseDescription,
|
pauseDescription = pauseDescription ?: fallback.pauseDescription,
|
||||||
|
released = released ?: fallback.released,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||||
|
|
||||||
|
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
): String? = when {
|
||||||
|
isNextUp && useEpisodeThumbnails -> firstNonBlank(
|
||||||
|
episodeThumbnail,
|
||||||
|
poster,
|
||||||
|
background,
|
||||||
|
imageUrl,
|
||||||
|
)
|
||||||
|
isNextUp -> firstNonBlank(
|
||||||
|
poster,
|
||||||
|
background,
|
||||||
|
episodeThumbnail,
|
||||||
|
imageUrl,
|
||||||
|
)
|
||||||
|
useEpisodeThumbnails -> firstNonBlank(
|
||||||
|
episodeThumbnail,
|
||||||
|
poster,
|
||||||
|
background,
|
||||||
|
imageUrl,
|
||||||
|
)
|
||||||
|
else -> firstNonBlank(
|
||||||
|
poster,
|
||||||
|
background,
|
||||||
|
episodeThumbnail,
|
||||||
|
imageUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstNonBlank(vararg values: String?): String? =
|
||||||
|
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HomeContinueWatchingSection(
|
internal fun HomeContinueWatchingSection(
|
||||||
items: List<ContinueWatchingItem>,
|
items: List<ContinueWatchingItem>,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
|
useEpisodeThumbnails: Boolean = true,
|
||||||
|
blurNextUp: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sectionPadding: Dp? = null,
|
sectionPadding: Dp? = null,
|
||||||
layout: ContinueWatchingLayout? = null,
|
layout: ContinueWatchingLayout? = null,
|
||||||
|
|
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
|
||||||
HomeContinueWatchingSectionContent(
|
HomeContinueWatchingSectionContent(
|
||||||
items = items,
|
items = items,
|
||||||
style = style,
|
style = style,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
sectionPadding = sectionPadding,
|
sectionPadding = sectionPadding,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
|
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
|
||||||
HomeContinueWatchingSectionContent(
|
HomeContinueWatchingSectionContent(
|
||||||
items = items,
|
items = items,
|
||||||
style = style,
|
style = style,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||||
layout = rememberContinueWatchingLayout(maxWidth.value),
|
layout = rememberContinueWatchingLayout(maxWidth.value),
|
||||||
|
|
@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection(
|
||||||
private fun HomeContinueWatchingSectionContent(
|
private fun HomeContinueWatchingSectionContent(
|
||||||
items: List<ContinueWatchingItem>,
|
items: List<ContinueWatchingItem>,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
sectionPadding: Dp,
|
sectionPadding: Dp,
|
||||||
layout: ContinueWatchingLayout,
|
layout: ContinueWatchingLayout,
|
||||||
|
|
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
|
||||||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||||
item = item,
|
item = item,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
onClick = onItemClick?.let { { it(item) } },
|
onClick = onItemClick?.let { { it(item) } },
|
||||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||||
)
|
)
|
||||||
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
layout = layout,
|
layout = layout,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
onClick = onItemClick?.let { { it(item) } },
|
onClick = onItemClick?.let { { it(item) } },
|
||||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||||
)
|
)
|
||||||
|
|
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
|
||||||
private fun ContinueWatchingWideCard(
|
private fun ContinueWatchingWideCard(
|
||||||
item: ContinueWatchingItem,
|
item: ContinueWatchingItem,
|
||||||
layout: ContinueWatchingLayout,
|
layout: ContinueWatchingLayout,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
|
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val artworkUrl = item.poster ?: item.background ?: item.imageUrl
|
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||||
|
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||||
ArtworkPanel(
|
ArtworkPanel(
|
||||||
imageUrl = artworkUrl,
|
imageUrl = artworkUrl,
|
||||||
width = layout.widePosterStripWidth,
|
width = layout.widePosterStripWidth,
|
||||||
|
blurred = shouldBlurArtwork,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
|
||||||
private fun ContinueWatchingPosterCard(
|
private fun ContinueWatchingPosterCard(
|
||||||
item: ContinueWatchingItem,
|
item: ContinueWatchingItem,
|
||||||
layout: ContinueWatchingLayout,
|
layout: ContinueWatchingLayout,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)?,
|
onLongClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
|
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
|
||||||
)
|
)
|
||||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
) {
|
) {
|
||||||
val imageUrl = item.poster ?: item.imageUrl
|
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||||
|
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = item.title,
|
contentDescription = item.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -489,6 +543,7 @@ private fun ContinueWatchingPosterCard(
|
||||||
private fun ArtworkPanel(
|
private fun ArtworkPanel(
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
|
blurred: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -500,7 +555,9 @@ private fun ArtworkPanel(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktLibraryRepository
|
import com.nuvio.app.features.trakt.TraktLibraryRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktListTab
|
||||||
|
import com.nuvio.app.features.trakt.TraktListType
|
||||||
import com.nuvio.app.features.trakt.TraktMembershipChanges
|
import com.nuvio.app.features.trakt.TraktMembershipChanges
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
|
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
|
||||||
|
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
|
||||||
import io.github.jan.supabase.postgrest.postgrest
|
import io.github.jan.supabase.postgrest.postgrest
|
||||||
import io.github.jan.supabase.postgrest.rpc
|
import io.github.jan.supabase.postgrest.rpc
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -65,12 +72,28 @@ object LibraryRepository {
|
||||||
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
TraktLibraryRepository.preloadListTabsAsync()
|
TraktLibraryRepository.preloadListTabsAsync()
|
||||||
runCatching { TraktLibraryRepository.refreshNow() }
|
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
|
||||||
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
|
runCatching { TraktLibraryRepository.refreshNow() }
|
||||||
|
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncScope.launch {
|
||||||
|
TraktSettingsRepository.uiState
|
||||||
|
.map { it.librarySourceMode }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collectLatest { source ->
|
||||||
|
if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
|
||||||
|
TraktLibraryRepository.preloadListTabsAsync()
|
||||||
|
publish()
|
||||||
|
refreshTraktLibraryAsync()
|
||||||
|
} else {
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
TraktLibraryRepository.uiState.collectLatest {
|
TraktLibraryRepository.uiState.collectLatest {
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
|
|
@ -82,23 +105,29 @@ object LibraryRepository {
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
TraktAuthRepository.ensureLoaded()
|
TraktAuthRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
TraktLibraryRepository.ensureLoaded()
|
TraktLibraryRepository.ensureLoaded()
|
||||||
if (hasLoaded) return
|
if (hasLoaded) return
|
||||||
loadFromDisk(ProfileRepository.activeProfileId)
|
loadFromDisk(ProfileRepository.activeProfileId)
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
TraktLibraryRepository.preloadListTabsAsync()
|
TraktLibraryRepository.preloadListTabsAsync()
|
||||||
refreshTraktLibraryAsync()
|
if (isTraktLibrarySourceActive()) {
|
||||||
|
refreshTraktLibraryAsync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onProfileChanged(profileId: Int) {
|
fun onProfileChanged(profileId: Int) {
|
||||||
if (profileId == currentProfileId && hasLoaded) return
|
if (profileId == currentProfileId && hasLoaded) return
|
||||||
|
TraktSettingsRepository.onProfileChanged()
|
||||||
loadFromDisk(profileId)
|
loadFromDisk(profileId)
|
||||||
TraktAuthRepository.onProfileChanged()
|
TraktAuthRepository.onProfileChanged()
|
||||||
TraktLibraryRepository.onProfileChanged()
|
TraktLibraryRepository.onProfileChanged()
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
TraktLibraryRepository.preloadListTabsAsync()
|
TraktLibraryRepository.preloadListTabsAsync()
|
||||||
refreshTraktLibraryAsync()
|
if (isTraktLibrarySourceActive()) {
|
||||||
|
refreshTraktLibraryAsync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +159,7 @@ object LibraryRepository {
|
||||||
suspend fun pullFromServer(profileId: Int) {
|
suspend fun pullFromServer(profileId: Int) {
|
||||||
currentProfileId = profileId
|
currentProfileId = profileId
|
||||||
|
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (isTraktLibrarySourceActive()) {
|
||||||
runCatching { TraktLibraryRepository.refreshNow() }
|
runCatching { TraktLibraryRepository.refreshNow() }
|
||||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
|
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -157,7 +186,7 @@ object LibraryRepository {
|
||||||
fun toggleSaved(item: LibraryItem) {
|
fun toggleSaved(item: LibraryItem) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (isTraktLibrarySourceActive()) {
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
|
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
|
||||||
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
|
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
|
||||||
|
|
@ -175,7 +204,6 @@ object LibraryRepository {
|
||||||
|
|
||||||
fun save(item: LibraryItem) {
|
fun save(item: LibraryItem) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (TraktAuthRepository.isAuthenticated.value) return
|
|
||||||
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||||
publish()
|
publish()
|
||||||
persist()
|
persist()
|
||||||
|
|
@ -184,7 +212,6 @@ object LibraryRepository {
|
||||||
|
|
||||||
fun remove(id: String) {
|
fun remove(id: String) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (TraktAuthRepository.isAuthenticated.value) return
|
|
||||||
if (itemsById.remove(id) != null) {
|
if (itemsById.remove(id) != null) {
|
||||||
publish()
|
publish()
|
||||||
persist()
|
persist()
|
||||||
|
|
@ -195,7 +222,7 @@ object LibraryRepository {
|
||||||
fun isSaved(id: String, type: String? = null): Boolean {
|
fun isSaved(id: String, type: String? = null): Boolean {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (isTraktLibrarySourceActive()) {
|
||||||
if (type != null) {
|
if (type != null) {
|
||||||
return TraktLibraryRepository.isInAnyList(id, type)
|
return TraktLibraryRepository.isInAnyList(id, type)
|
||||||
}
|
}
|
||||||
|
|
@ -212,46 +239,65 @@ object LibraryRepository {
|
||||||
fun savedItem(id: String): LibraryItem? {
|
fun savedItem(id: String): LibraryItem? {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (isTraktLibrarySourceActive()) {
|
||||||
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
|
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
return itemsById[id]
|
return itemsById[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
fun traktListTabs() = TraktLibraryRepository.currentListTabs()
|
fun libraryListTabs(): List<TraktListTab> {
|
||||||
|
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
|
TraktLibraryRepository.currentListTabs()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
return libraryTabsWithLocal(traktTabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
|
||||||
|
|
||||||
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
|
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
|
||||||
return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
|
||||||
}
|
|
||||||
val inLocal = itemsById.containsKey(item.id)
|
val inLocal = itemsById.containsKey(item.id)
|
||||||
return mapOf(LOCAL_LIST_KEY to inLocal)
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
|
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||||
|
return libraryMembershipWithLocal(
|
||||||
|
inLocal = inLocal,
|
||||||
|
traktMembership = traktMembership,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return libraryMembershipWithLocal(inLocal = inLocal)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
|
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
|
||||||
TraktLibraryRepository.applyMembershipChanges(
|
val currentlyInLocal = itemsById.containsKey(item.id)
|
||||||
item = item,
|
if (localDesired != currentlyInLocal) {
|
||||||
changes = TraktMembershipChanges(desiredMembership = desiredMembership),
|
if (localDesired) {
|
||||||
)
|
save(item)
|
||||||
publish()
|
} else {
|
||||||
return
|
remove(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val shouldBeSaved = desiredMembership.values.any { it }
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
if (shouldBeSaved) {
|
val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
|
||||||
save(item)
|
if (traktMembership.isNotEmpty()) {
|
||||||
|
TraktLibraryRepository.applyMembershipChanges(
|
||||||
|
item = item,
|
||||||
|
changes = TraktMembershipChanges(desiredMembership = traktMembership),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
publish()
|
||||||
} else {
|
} else {
|
||||||
remove(item.id)
|
publish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pushToServer() {
|
private fun pushToServer() {
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
if (TraktAuthRepository.isAuthenticated.value) return@launch
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
val syncItems = itemsById.values.map { it.toSyncItem() }
|
val syncItems = itemsById.values.map { it.toSyncItem() }
|
||||||
|
|
@ -267,7 +313,7 @@ object LibraryRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (isTraktLibrarySourceActive()) {
|
||||||
val traktState = TraktLibraryRepository.uiState.value
|
val traktState = TraktLibraryRepository.uiState.value
|
||||||
val sections = traktState.listTabs.mapNotNull { tab ->
|
val sections = traktState.listTabs.mapNotNull { tab ->
|
||||||
val listItems = traktState.entriesByList[tab.key].orEmpty()
|
val listItems = traktState.entriesByList[tab.key].orEmpty()
|
||||||
|
|
@ -334,9 +380,42 @@ object LibraryRepository {
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selectedLibrarySourceMode(): LibrarySourceMode {
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
|
return TraktSettingsRepository.uiState.value.librarySourceMode
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
|
||||||
|
resolveEffectiveLibrarySourceMode(
|
||||||
|
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||||
|
source = selectedLibrarySourceMode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun isTraktLibrarySourceActive(): Boolean =
|
||||||
|
effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val LOCAL_LIST_KEY = "local"
|
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
|
||||||
|
internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library"
|
||||||
|
|
||||||
|
internal fun localLibraryListTab(): TraktListTab =
|
||||||
|
TraktListTab(
|
||||||
|
key = LOCAL_LIBRARY_LIST_KEY,
|
||||||
|
title = LOCAL_LIBRARY_LIST_TITLE,
|
||||||
|
type = TraktListType.WATCHLIST,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun libraryTabsWithLocal(traktTabs: List<TraktListTab>): List<TraktListTab> =
|
||||||
|
listOf(localLibraryListTab()) + traktTabs
|
||||||
|
|
||||||
|
internal fun libraryMembershipWithLocal(
|
||||||
|
inLocal: Boolean,
|
||||||
|
traktMembership: Map<String, Boolean> = emptyMap(),
|
||||||
|
): Map<String, Boolean> =
|
||||||
|
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
|
||||||
|
putAll(traktMembership)
|
||||||
|
}
|
||||||
|
|
||||||
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
||||||
id = contentId,
|
id = contentId,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ fun LibraryScreen(
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
||||||
|
val retryLibraryLoad: () -> Unit = {
|
||||||
|
NetworkStatusRepository.requestRefresh(force = true)
|
||||||
|
coroutineScope.launch {
|
||||||
|
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
|
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
|
||||||
when (networkStatusUiState.condition) {
|
when (networkStatusUiState.condition) {
|
||||||
|
|
@ -110,14 +116,7 @@ fun LibraryScreen(
|
||||||
NuvioNetworkOfflineCard(
|
NuvioNetworkOfflineCard(
|
||||||
condition = networkStatusUiState.condition,
|
condition = networkStatusUiState.condition,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
onRetry = {
|
onRetry = retryLibraryLoad,
|
||||||
NetworkStatusRepository.requestRefresh(force = true)
|
|
||||||
if (isTraktSource) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
|
|
@ -128,6 +127,8 @@ fun LibraryScreen(
|
||||||
stringResource(Res.string.library_load_failed)
|
stringResource(Res.string.library_load_failed)
|
||||||
},
|
},
|
||||||
message = uiState.errorMessage.orEmpty(),
|
message = uiState.errorMessage.orEmpty(),
|
||||||
|
actionLabel = stringResource(Res.string.action_retry),
|
||||||
|
onActionClick = retryLibraryLoad,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,12 +140,7 @@ fun LibraryScreen(
|
||||||
NuvioNetworkOfflineCard(
|
NuvioNetworkOfflineCard(
|
||||||
condition = networkStatusUiState.condition,
|
condition = networkStatusUiState.condition,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
onRetry = {
|
onRetry = retryLibraryLoad,
|
||||||
NetworkStatusRepository.requestRefresh(force = true)
|
|
||||||
coroutineScope.launch {
|
|
||||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ internal fun PlayerControlsShell(
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
|
onSubmitIntroClick = onSubmitIntroClick,
|
||||||
onLockToggle = onLockToggle,
|
onLockToggle = onLockToggle,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -168,7 +169,6 @@ internal fun PlayerControlsShell(
|
||||||
onAudioClick = onAudioClick,
|
onAudioClick = onAudioClick,
|
||||||
onSourcesClick = onSourcesClick,
|
onSourcesClick = onSourcesClick,
|
||||||
onEpisodesClick = onEpisodesClick,
|
onEpisodesClick = onEpisodesClick,
|
||||||
onSubmitIntroClick = onSubmitIntroClick,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -189,6 +189,7 @@ private fun PlayerHeader(
|
||||||
episodeTitle: String?,
|
episodeTitle: String?,
|
||||||
metrics: PlayerLayoutMetrics,
|
metrics: PlayerLayoutMetrics,
|
||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
|
onSubmitIntroClick: (() -> Unit)?,
|
||||||
onLockToggle: () -> Unit,
|
onLockToggle: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -264,6 +265,15 @@ private fun PlayerHeader(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (onSubmitIntroClick != null) {
|
||||||
|
PlayerHeaderIconButton(
|
||||||
|
icon = Icons.Rounded.Flag,
|
||||||
|
contentDescription = "Submit Intro",
|
||||||
|
buttonSize = metrics.headerIconSize + 16.dp,
|
||||||
|
iconSize = metrics.headerIconSize,
|
||||||
|
onClick = onSubmitIntroClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
PlayerHeaderIconButton(
|
PlayerHeaderIconButton(
|
||||||
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
||||||
contentDescription = if (isLocked) {
|
contentDescription = if (isLocked) {
|
||||||
|
|
@ -424,7 +434,6 @@ private fun ProgressControls(
|
||||||
onAudioClick: () -> Unit,
|
onAudioClick: () -> Unit,
|
||||||
onSourcesClick: (() -> Unit)? = null,
|
onSourcesClick: (() -> Unit)? = null,
|
||||||
onEpisodesClick: (() -> Unit)? = null,
|
onEpisodesClick: (() -> Unit)? = null,
|
||||||
onSubmitIntroClick: (() -> Unit)? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
|
||||||
|
|
@ -506,13 +515,6 @@ private fun ProgressControls(
|
||||||
onClick = onEpisodesClick,
|
onClick = onEpisodesClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onSubmitIntroClick != null) {
|
|
||||||
PlayerActionPillButton(
|
|
||||||
label = "Submit Intro",
|
|
||||||
icon = Icons.Rounded.Flag,
|
|
||||||
onClick = onSubmitIntroClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -676,6 +678,9 @@ private fun PlayerActionPillButton(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.nuvioTypeScale.labelSm,
|
style = MaterialTheme.nuvioTypeScale.labelSm,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
softWrap = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
@ -60,6 +61,9 @@ import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||||
|
import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
|
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
fun PlayerEpisodesPanel(
|
fun PlayerEpisodesPanel(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
episodes: List<MetaVideo>,
|
episodes: List<MetaVideo>,
|
||||||
|
parentMetaType: String,
|
||||||
|
parentMetaId: String,
|
||||||
currentSeason: Int?,
|
currentSeason: Int?,
|
||||||
currentEpisode: Int?,
|
currentEpisode: Int?,
|
||||||
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
watchedKeys: Set<String>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
// episode stream sub-view state
|
// episode stream sub-view state
|
||||||
episodeStreamsState: EpisodeStreamsPanelState,
|
episodeStreamsState: EpisodeStreamsPanelState,
|
||||||
onSeasonSelected: (Int) -> Unit,
|
onSeasonSelected: (Int) -> Unit,
|
||||||
|
|
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
|
||||||
} else {
|
} else {
|
||||||
EpisodesListSubView(
|
EpisodesListSubView(
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
|
parentMetaType = parentMetaType,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
currentSeason = currentSeason,
|
currentSeason = currentSeason,
|
||||||
currentEpisode = currentEpisode,
|
currentEpisode = currentEpisode,
|
||||||
|
progressByVideoId = progressByVideoId,
|
||||||
|
watchedKeys = watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onSeasonSelected = onSeasonSelected,
|
onSeasonSelected = onSeasonSelected,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
|
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodesListSubView(
|
private fun EpisodesListSubView(
|
||||||
episodes: List<MetaVideo>,
|
episodes: List<MetaVideo>,
|
||||||
|
parentMetaType: String,
|
||||||
|
parentMetaId: String,
|
||||||
currentSeason: Int?,
|
currentSeason: Int?,
|
||||||
currentEpisode: Int?,
|
currentEpisode: Int?,
|
||||||
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
watchedKeys: Set<String>,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onSeasonSelected: (Int) -> Unit,
|
onSeasonSelected: (Int) -> Unit,
|
||||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
|
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
|
||||||
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
||||||
) { _, episode ->
|
) { _, episode ->
|
||||||
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
|
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
|
||||||
|
val episodeVideoId = buildPlaybackVideoId(
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
seasonNumber = episode.season,
|
||||||
|
episodeNumber = episode.episode,
|
||||||
|
fallbackVideoId = episode.id,
|
||||||
|
)
|
||||||
|
val isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
|
WatchingState.isEpisodeWatched(
|
||||||
|
watchedKeys = watchedKeys,
|
||||||
|
metaType = parentMetaType,
|
||||||
|
metaId = parentMetaId,
|
||||||
|
episode = episode,
|
||||||
|
)
|
||||||
EpisodeRow(
|
EpisodeRow(
|
||||||
episode = episode,
|
episode = episode,
|
||||||
isCurrent = isCurrent,
|
isCurrent = isCurrent,
|
||||||
|
isWatched = isWatched,
|
||||||
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onClick = { onEpisodeSelected(episode) },
|
onClick = { onEpisodeSelected(episode) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
|
||||||
private fun EpisodeRow(
|
private fun EpisodeRow(
|
||||||
episode: MetaVideo,
|
episode: MetaVideo,
|
||||||
isCurrent: Boolean,
|
isCurrent: Boolean,
|
||||||
|
isWatched: Boolean,
|
||||||
|
blurUnwatchedEpisodes: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -342,7 +379,8 @@ private fun EpisodeRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(80.dp)
|
.width(80.dp)
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.downloads.DownloadItem
|
import com.nuvio.app.features.downloads.DownloadItem
|
||||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||||
|
|
@ -55,6 +56,7 @@ import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||||
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
|
|
@ -143,6 +145,18 @@ fun PlayerScreen(
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
PlayerSettingsRepository.uiState
|
PlayerSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val metaScreenSettingsUiState by remember {
|
||||||
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
|
MetaScreenSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
val watchedUiState by remember {
|
||||||
|
WatchedRepository.ensureLoaded()
|
||||||
|
WatchedRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
val watchProgressUiState by remember {
|
||||||
|
WatchProgressRepository.ensureLoaded()
|
||||||
|
WatchProgressRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -1799,8 +1813,13 @@ fun PlayerScreen(
|
||||||
PlayerEpisodesPanel(
|
PlayerEpisodesPanel(
|
||||||
visible = showEpisodesPanel,
|
visible = showEpisodesPanel,
|
||||||
episodes = allEpisodes,
|
episodes = allEpisodes,
|
||||||
|
parentMetaType = parentMetaType,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
currentSeason = activeSeasonNumber,
|
currentSeason = activeSeasonNumber,
|
||||||
currentEpisode = activeEpisodeNumber,
|
currentEpisode = activeEpisodeNumber,
|
||||||
|
progressByVideoId = watchProgressUiState.byVideoId,
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||||
episodeStreamsState = episodeStreamsPanelState.copy(
|
episodeStreamsState = episodeStreamsPanelState.copy(
|
||||||
streamsUiState = episodeStreamsRepoState,
|
streamsUiState = episodeStreamsRepoState,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ fun ProfileEditScreen(
|
||||||
|
|
||||||
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
|
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
|
||||||
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
|
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
|
||||||
|
var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) }
|
||||||
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
|
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
|
||||||
var isSaving by remember { mutableStateOf(false) }
|
var isSaving by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
@ -90,17 +91,20 @@ fun ProfileEditScreen(
|
||||||
AvatarRepository.fetchAvatars()
|
AvatarRepository.fetchAvatars()
|
||||||
AvatarRepository.refreshAvatars()
|
AvatarRepository.refreshAvatars()
|
||||||
}
|
}
|
||||||
LaunchedEffect(isNew, avatars, selectedAvatarId) {
|
LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) {
|
||||||
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) {
|
if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) {
|
||||||
selectedAvatarId = avatars.first().id
|
selectedAvatarId = avatars.first().id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) }
|
||||||
|
val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null
|
||||||
val selectedAvatarItem = remember(selectedAvatarId, avatars) {
|
val selectedAvatarItem = remember(selectedAvatarId, avatars) {
|
||||||
selectedAvatarId?.let { id -> avatars.find { it.id == id } }
|
selectedAvatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
val previewAccent = remember(selectedAvatarItem, fallbackColorHex) {
|
val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null
|
||||||
parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex)
|
val previewAccent = remember(visibleAvatarItem, fallbackColorHex) {
|
||||||
|
parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioScreen(modifier = modifier) {
|
NuvioScreen(modifier = modifier) {
|
||||||
|
|
@ -123,12 +127,47 @@ fun ProfileEditScreen(
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
onNameChange = { name = it },
|
onNameChange = { name = it },
|
||||||
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
|
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
|
||||||
selectedAvatar = selectedAvatarItem,
|
selectedAvatar = visibleAvatarItem,
|
||||||
|
customAvatarUrl = customAvatarUrl,
|
||||||
accentColor = previewAccent,
|
accentColor = previewAccent,
|
||||||
hasAvatarChoices = avatars.isNotEmpty(),
|
hasAvatarChoices = avatars.isNotEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
NuvioSurfaceCard {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.profile_custom_avatar_url),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.profile_custom_avatar_url_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
NuvioInputField(
|
||||||
|
value = avatarUrl,
|
||||||
|
onValueChange = { value ->
|
||||||
|
avatarUrl = value
|
||||||
|
if (value.isNotBlank()) {
|
||||||
|
selectedAvatarId = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = stringResource(Res.string.profile_custom_avatar_url_placeholder),
|
||||||
|
)
|
||||||
|
if (avatarUrlIsInvalid) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.profile_avatar_url_invalid),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
|
@ -165,8 +204,11 @@ fun ProfileEditScreen(
|
||||||
AvatarChoiceItem(
|
AvatarChoiceItem(
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
size = avatarSize,
|
size = avatarSize,
|
||||||
isSelected = avatar.id == selectedAvatarId,
|
isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId,
|
||||||
onClick = { selectedAvatarId = avatar.id },
|
onClick = {
|
||||||
|
avatarUrl = ""
|
||||||
|
selectedAvatarId = avatar.id
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,16 +262,17 @@ fun ProfileEditScreen(
|
||||||
} else {
|
} else {
|
||||||
stringResource(Res.string.collections_editor_save_changes)
|
stringResource(Res.string.collections_editor_save_changes)
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && !isSaving,
|
enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving,
|
||||||
onClick = {
|
onClick = {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex
|
val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
ProfileRepository.createProfile(
|
ProfileRepository.createProfile(
|
||||||
name = name,
|
name = name,
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
avatarId = selectedAvatarId,
|
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
|
||||||
|
avatarUrl = customAvatarUrl,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -237,7 +280,8 @@ fun ProfileEditScreen(
|
||||||
profileIndex = currentProfile!!.profileIndex,
|
profileIndex = currentProfile!!.profileIndex,
|
||||||
name = name,
|
name = name,
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
avatarId = selectedAvatarId,
|
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
|
||||||
|
avatarUrl = customAvatarUrl,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +374,7 @@ private fun ProfileIdentityCard(
|
||||||
onNameChange: (String) -> Unit,
|
onNameChange: (String) -> Unit,
|
||||||
onUsesPrimaryAddonsChange: (Boolean) -> Unit,
|
onUsesPrimaryAddonsChange: (Boolean) -> Unit,
|
||||||
selectedAvatar: AvatarCatalogItem?,
|
selectedAvatar: AvatarCatalogItem?,
|
||||||
|
customAvatarUrl: String?,
|
||||||
accentColor: Color,
|
accentColor: Color,
|
||||||
hasAvatarChoices: Boolean,
|
hasAvatarChoices: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
@ -345,16 +390,31 @@ private fun ProfileIdentityCard(
|
||||||
.size(88.dp)
|
.size(88.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f),
|
if (selectedAvatar != null || customAvatarUrl != null) {
|
||||||
|
accentColor
|
||||||
|
} else {
|
||||||
|
accentColor.copy(alpha = 0.18f)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.border(
|
.border(
|
||||||
width = 2.dp,
|
width = 2.dp,
|
||||||
color = if (selectedAvatar == null) accentColor.copy(alpha = 0.35f) else Color.Transparent,
|
color = if (selectedAvatar == null && customAvatarUrl == null) {
|
||||||
|
accentColor.copy(alpha = 0.35f)
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
},
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (selectedAvatar != null) {
|
if (customAvatarUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = customAvatarUrl,
|
||||||
|
contentDescription = name,
|
||||||
|
modifier = Modifier.size(88.dp).clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else if (selectedAvatar != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(selectedAvatar.storagePath),
|
model = avatarStorageUrl(selectedAvatar.storagePath),
|
||||||
contentDescription = selectedAvatar.displayName,
|
contentDescription = selectedAvatar.displayName,
|
||||||
|
|
@ -410,6 +470,7 @@ private fun ProfileIdentityCard(
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
|
customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected)
|
||||||
selectedAvatar != null -> stringResource(
|
selectedAvatar != null -> stringResource(
|
||||||
Res.string.profile_avatar_selected,
|
Res.string.profile_avatar_selected,
|
||||||
selectedAvatar.displayName,
|
selectedAvatar.displayName,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ data class NuvioProfile(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
|
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
|
||||||
@SerialName("avatar_id") val avatarId: String? = null,
|
@SerialName("avatar_id") val avatarId: String? = null,
|
||||||
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
|
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
|
||||||
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
||||||
@SerialName("pin_enabled") val pinEnabled: Boolean = false,
|
@SerialName("pin_enabled") val pinEnabled: Boolean = false,
|
||||||
|
|
@ -28,6 +29,7 @@ data class ProfilePushPayload(
|
||||||
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
|
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
|
||||||
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
||||||
@SerialName("avatar_id") val avatarId: String? = null,
|
@SerialName("avatar_id") val avatarId: String? = null,
|
||||||
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf(
|
||||||
|
|
||||||
fun avatarStorageUrl(storagePath: String): String =
|
fun avatarStorageUrl(storagePath: String): String =
|
||||||
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
|
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
|
||||||
|
|
||||||
|
fun normalizedAvatarUrl(url: String?): String? =
|
||||||
|
url?.trim()?.takeIf { it.isValidAvatarUrl() }
|
||||||
|
|
||||||
|
fun String.isValidAvatarUrl(): Boolean {
|
||||||
|
val value = trim()
|
||||||
|
return value.length <= 2048 &&
|
||||||
|
!value.any { it.isWhitespace() } &&
|
||||||
|
(value.startsWith("https://") || value.startsWith("http://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): String? =
|
||||||
|
normalizedAvatarUrl(profile.avatarUrl)
|
||||||
|
?: avatar
|
||||||
|
?.storagePath
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let(::avatarStorageUrl)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import com.nuvio.app.features.plugins.PluginRepository
|
||||||
import com.nuvio.app.features.search.SearchHistoryRepository
|
import com.nuvio.app.features.search.SearchHistoryRepository
|
||||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
|
|
@ -135,6 +136,7 @@ object ProfileRepository {
|
||||||
)
|
)
|
||||||
persist()
|
persist()
|
||||||
WatchedRepository.onProfileChanged(profileIndex)
|
WatchedRepository.onProfileChanged(profileIndex)
|
||||||
|
TraktSettingsRepository.onProfileChanged()
|
||||||
LibraryRepository.onProfileChanged(profileIndex)
|
LibraryRepository.onProfileChanged(profileIndex)
|
||||||
WatchProgressRepository.onProfileChanged(profileIndex)
|
WatchProgressRepository.onProfileChanged(profileIndex)
|
||||||
AddonRepository.onProfileChanged(profileIndex)
|
AddonRepository.onProfileChanged(profileIndex)
|
||||||
|
|
@ -177,6 +179,7 @@ object ProfileRepository {
|
||||||
name: String,
|
name: String,
|
||||||
avatarColorHex: String,
|
avatarColorHex: String,
|
||||||
avatarId: String? = null,
|
avatarId: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
usesPrimaryAddons: Boolean = false,
|
usesPrimaryAddons: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val existing = _state.value.profiles
|
val existing = _state.value.profiles
|
||||||
|
|
@ -190,6 +193,7 @@ object ProfileRepository {
|
||||||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||||
avatarId = profile.avatarId,
|
avatarId = profile.avatarId,
|
||||||
|
avatarUrl = profile.avatarUrl,
|
||||||
)
|
)
|
||||||
} + ProfilePushPayload(
|
} + ProfilePushPayload(
|
||||||
profileIndex = nextIndex,
|
profileIndex = nextIndex,
|
||||||
|
|
@ -197,6 +201,7 @@ object ProfileRepository {
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
avatarId = avatarId,
|
avatarId = avatarId,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
pushProfiles(allPayloads)
|
pushProfiles(allPayloads)
|
||||||
|
|
@ -207,6 +212,7 @@ object ProfileRepository {
|
||||||
name: String,
|
name: String,
|
||||||
avatarColorHex: String,
|
avatarColorHex: String,
|
||||||
avatarId: String? = null,
|
avatarId: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
usesPrimaryAddons: Boolean = false,
|
usesPrimaryAddons: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val allPayloads = _state.value.profiles.map { profile ->
|
val allPayloads = _state.value.profiles.map { profile ->
|
||||||
|
|
@ -216,7 +222,8 @@ object ProfileRepository {
|
||||||
name = name,
|
name = name,
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
avatarId = avatarId ?: profile.avatarId,
|
avatarId = avatarId,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ProfilePushPayload(
|
ProfilePushPayload(
|
||||||
|
|
@ -226,6 +233,7 @@ object ProfileRepository {
|
||||||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||||
avatarId = profile.avatarId,
|
avatarId = profile.avatarId,
|
||||||
|
avatarUrl = profile.avatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -355,6 +363,7 @@ object ProfileRepository {
|
||||||
name = p.name,
|
name = p.name,
|
||||||
avatarColorHex = p.avatarColorHex,
|
avatarColorHex = p.avatarColorHex,
|
||||||
avatarId = p.avatarId,
|
avatarId = p.avatarId,
|
||||||
|
avatarUrl = p.avatarUrl,
|
||||||
usesPrimaryAddons = p.usesPrimaryAddons,
|
usesPrimaryAddons = p.usesPrimaryAddons,
|
||||||
usesPrimaryPlugins = p.usesPrimaryPlugins,
|
usesPrimaryPlugins = p.usesPrimaryPlugins,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,9 @@ private fun ProfileAvatarCard(
|
||||||
val avatarItem = remember(profile.avatarId, avatars) {
|
val avatarItem = remember(profile.avatarId, avatars) {
|
||||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||||
|
profileAvatarImageUrl(profile, avatarItem)
|
||||||
|
}
|
||||||
|
|
||||||
val animAlpha = remember { Animatable(0f) }
|
val animAlpha = remember { Animatable(0f) }
|
||||||
val animScale = remember { Animatable(0.85f) }
|
val animScale = remember { Animatable(0.85f) }
|
||||||
|
|
@ -342,8 +345,8 @@ private fun ProfileAvatarCard(
|
||||||
modifier = Modifier.size(110.dp),
|
modifier = Modifier.size(110.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(110.dp)
|
.size(110.dp)
|
||||||
|
|
@ -364,15 +367,15 @@ private fun ProfileAvatarCard(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
|
if (avatarImageUrl == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
|
||||||
else Modifier,
|
else Modifier,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(avatarItem.storagePath),
|
model = avatarImageUrl,
|
||||||
contentDescription = avatarItem.displayName,
|
contentDescription = avatarItem?.displayName ?: profile.name,
|
||||||
modifier = Modifier.size(100.dp).clip(CircleShape),
|
modifier = Modifier.size(100.dp).clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,9 @@ private fun PopupProfileBubble(
|
||||||
val avatarItem = remember(profile.avatarId, avatars) {
|
val avatarItem = remember(profile.avatarId, avatars) {
|
||||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||||
|
profileAvatarImageUrl(profile, avatarItem)
|
||||||
|
}
|
||||||
|
|
||||||
// Per-item entrance animation
|
// Per-item entrance animation
|
||||||
val itemAlpha = remember { Animatable(0f) }
|
val itemAlpha = remember { Animatable(0f) }
|
||||||
|
|
@ -393,8 +396,8 @@ private fun PopupProfileBubble(
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||||
} else {
|
} else {
|
||||||
avatarColor.copy(alpha = 0.15f)
|
avatarColor.copy(alpha = 0.15f)
|
||||||
},
|
},
|
||||||
|
|
@ -411,7 +414,7 @@ private fun PopupProfileBubble(
|
||||||
avatarColor.copy(alpha = 0.6f),
|
avatarColor.copy(alpha = 0.6f),
|
||||||
CircleShape,
|
CircleShape,
|
||||||
)
|
)
|
||||||
avatarItem == null -> Modifier.border(
|
avatarImageUrl == null -> Modifier.border(
|
||||||
1.5.dp,
|
1.5.dp,
|
||||||
avatarColor.copy(alpha = 0.3f),
|
avatarColor.copy(alpha = 0.3f),
|
||||||
CircleShape,
|
CircleShape,
|
||||||
|
|
@ -421,9 +424,9 @@ private fun PopupProfileBubble(
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(avatarItem.storagePath),
|
model = avatarImageUrl,
|
||||||
contentDescription = profile.name,
|
contentDescription = profile.name,
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
|
@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar(
|
||||||
val avatarItem = remember(profile.avatarId, avatars) {
|
val avatarItem = remember(profile.avatarId, avatars) {
|
||||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||||
|
profileAvatarImageUrl(profile, avatarItem)
|
||||||
|
}
|
||||||
|
|
||||||
val borderColor = if (selected) {
|
val borderColor = if (selected) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
|
|
@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar(
|
||||||
.size(size.dp)
|
.size(size.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||||
} else {
|
} else {
|
||||||
avatarColor.copy(alpha = 0.15f)
|
avatarColor.copy(alpha = 0.15f)
|
||||||
},
|
},
|
||||||
|
|
@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar(
|
||||||
.border(1.5.dp, borderColor, CircleShape),
|
.border(1.5.dp, borderColor, CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(avatarItem.storagePath),
|
model = avatarImageUrl,
|
||||||
contentDescription = profile.name,
|
contentDescription = profile.name,
|
||||||
modifier = Modifier.size(size.dp).clip(CircleShape),
|
modifier = Modifier.size(size.dp).clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,16 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||||
import com.nuvio.app.features.addons.AddonCatalog
|
import com.nuvio.app.features.addons.AddonCatalog
|
||||||
import com.nuvio.app.features.addons.AddonExtraProperty
|
import com.nuvio.app.features.addons.AddonExtraProperty
|
||||||
import com.nuvio.app.features.addons.ManagedAddon
|
import com.nuvio.app.features.addons.ManagedAddon
|
||||||
|
import com.nuvio.app.features.catalog.CatalogPage
|
||||||
import com.nuvio.app.features.catalog.buildCatalogUrl
|
import com.nuvio.app.features.catalog.buildCatalogUrl
|
||||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||||
import com.nuvio.app.features.catalog.supportsPagination
|
import com.nuvio.app.features.catalog.supportsPagination
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.HomeCatalogSection
|
import com.nuvio.app.features.home.HomeCatalogSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
import com.nuvio.app.features.home.filterReleasedItems
|
||||||
|
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -37,6 +41,7 @@ object SearchRepository {
|
||||||
private var activeDiscoverJob: Job? = null
|
private var activeDiscoverJob: Job? = null
|
||||||
private var lastRequestKey: String? = null
|
private var lastRequestKey: String? = null
|
||||||
private var discoverSources: List<DiscoverCatalogOption> = emptyList()
|
private var discoverSources: List<DiscoverCatalogOption> = emptyList()
|
||||||
|
private var lastDiscoverHideUnreleasedContent: Boolean? = null
|
||||||
|
|
||||||
fun search(query: String, addons: List<ManagedAddon>) {
|
fun search(query: String, addons: List<ManagedAddon>) {
|
||||||
val normalizedQuery = query.trim()
|
val normalizedQuery = query.trim()
|
||||||
|
|
@ -71,6 +76,8 @@ object SearchRepository {
|
||||||
val requestKey = buildString {
|
val requestKey = buildString {
|
||||||
append(normalizedQuery.lowercase())
|
append(normalizedQuery.lowercase())
|
||||||
append('|')
|
append('|')
|
||||||
|
append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent)
|
||||||
|
append('|')
|
||||||
append(
|
append(
|
||||||
requests.joinToString(separator = "|") { request ->
|
requests.joinToString(separator = "|") { request ->
|
||||||
"${request.addon.manifestUrl}:${request.type}:${request.catalogId}"
|
"${request.addon.manifestUrl}:${request.type}:${request.catalogId}"
|
||||||
|
|
@ -119,6 +126,7 @@ object SearchRepository {
|
||||||
activeDiscoverJob?.cancel()
|
activeDiscoverJob?.cancel()
|
||||||
lastRequestKey = null
|
lastRequestKey = null
|
||||||
discoverSources = emptyList()
|
discoverSources = emptyList()
|
||||||
|
lastDiscoverHideUnreleasedContent = null
|
||||||
_uiState.value = SearchUiState()
|
_uiState.value = SearchUiState()
|
||||||
_discoverUiState.value = DiscoverUiState()
|
_discoverUiState.value = DiscoverUiState()
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +136,7 @@ object SearchRepository {
|
||||||
if (activeAddons.isEmpty()) {
|
if (activeAddons.isEmpty()) {
|
||||||
activeDiscoverJob?.cancel()
|
activeDiscoverJob?.cancel()
|
||||||
discoverSources = emptyList()
|
discoverSources = emptyList()
|
||||||
|
lastDiscoverHideUnreleasedContent = null
|
||||||
log.d { "Discover refresh aborted: no active addons" }
|
log.d { "Discover refresh aborted: no active addons" }
|
||||||
_discoverUiState.value = DiscoverUiState(
|
_discoverUiState.value = DiscoverUiState(
|
||||||
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
|
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
|
||||||
|
|
@ -137,7 +146,12 @@ object SearchRepository {
|
||||||
|
|
||||||
val sources = buildDiscoverSources(activeAddons)
|
val sources = buildDiscoverSources(activeAddons)
|
||||||
val current = _discoverUiState.value
|
val current = _discoverUiState.value
|
||||||
if (sources == discoverSources && current.canReuseDiscoverState(sources)) {
|
val hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent
|
||||||
|
if (
|
||||||
|
sources == discoverSources &&
|
||||||
|
lastDiscoverHideUnreleasedContent == hideUnreleasedContent &&
|
||||||
|
current.canReuseDiscoverState(sources)
|
||||||
|
) {
|
||||||
log.d {
|
log.d {
|
||||||
"Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " +
|
"Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " +
|
||||||
"genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}"
|
"genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}"
|
||||||
|
|
@ -146,6 +160,7 @@ object SearchRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
discoverSources = sources
|
discoverSources = sources
|
||||||
|
lastDiscoverHideUnreleasedContent = hideUnreleasedContent
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
activeDiscoverJob?.cancel()
|
activeDiscoverJob?.cancel()
|
||||||
log.d { "Discover refresh found no compatible discover catalogs" }
|
log.d { "Discover refresh found no compatible discover catalogs" }
|
||||||
|
|
@ -310,7 +325,7 @@ object SearchRepository {
|
||||||
type = type,
|
type = type,
|
||||||
catalogId = catalogId,
|
catalogId = catalogId,
|
||||||
search = query,
|
search = query,
|
||||||
)
|
).withUnreleasedFilter()
|
||||||
val items = page.items
|
val items = page.items
|
||||||
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
|
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
|
||||||
|
|
||||||
|
|
@ -364,7 +379,7 @@ object SearchRepository {
|
||||||
catalogId = selectedCatalog.catalogId,
|
catalogId = selectedCatalog.catalogId,
|
||||||
genre = current.selectedGenre,
|
genre = current.selectedGenre,
|
||||||
skip = requestedSkip.takeIf { it > 0 },
|
skip = requestedSkip.takeIf { it > 0 },
|
||||||
)
|
).withUnreleasedFilter()
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { page ->
|
onSuccess = { page ->
|
||||||
val latest = _discoverUiState.value
|
val latest = _discoverUiState.value
|
||||||
|
|
@ -421,6 +436,12 @@ object SearchRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||||
|
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||||
|
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||||
|
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||||
|
}
|
||||||
|
|
||||||
private data class SearchCatalogRequest(
|
private data class SearchCatalogRequest(
|
||||||
val addon: ManagedAddon,
|
val addon: ManagedAddon,
|
||||||
val catalogId: String,
|
val catalogId: String,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
|
|
@ -88,6 +89,7 @@ fun SearchScreen(
|
||||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
|
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
|
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
|
||||||
|
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
|
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
@ -123,11 +125,11 @@ fun SearchScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(addonRefreshKey) {
|
LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||||
SearchRepository.refreshDiscover(addonsUiState.addons)
|
SearchRepository.refreshDiscover(addonsUiState.addons)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(query, addonRefreshKey) {
|
LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||||
val normalizedQuery = query.trim()
|
val normalizedQuery = query.trim()
|
||||||
if (normalizedQuery.isBlank()) {
|
if (normalizedQuery.isBlank()) {
|
||||||
lastRequestedQuery = null
|
lastRequestedQuery = null
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she
|
||||||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
|
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
|
||||||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
|
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
|
||||||
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
|
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass
|
||||||
|
import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description
|
||||||
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
|
import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description
|
||||||
import nuvio.composeapp.generated.resources.settings_appearance_section_display
|
import nuvio.composeapp.generated.resources.settings_appearance_section_display
|
||||||
import nuvio.composeapp.generated.resources.settings_appearance_section_home
|
import nuvio.composeapp.generated.resources.settings_appearance_section_home
|
||||||
|
|
@ -70,6 +72,9 @@ internal fun LazyListScope.appearanceSettingsContent(
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
onAmoledToggle: (Boolean) -> Unit,
|
onAmoledToggle: (Boolean) -> Unit,
|
||||||
|
liquidGlassNativeTabBarSupported: Boolean,
|
||||||
|
liquidGlassNativeTabBarEnabled: Boolean,
|
||||||
|
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
|
||||||
selectedAppLanguage: AppLanguage,
|
selectedAppLanguage: AppLanguage,
|
||||||
onAppLanguageSelected: (AppLanguage) -> Unit,
|
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||||
onContinueWatchingClick: () -> Unit,
|
onContinueWatchingClick: () -> Unit,
|
||||||
|
|
@ -118,6 +123,16 @@ internal fun LazyListScope.appearanceSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = onAmoledToggle,
|
onCheckedChange = onAmoledToggle,
|
||||||
)
|
)
|
||||||
|
if (liquidGlassNativeTabBarSupported) {
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_appearance_liquid_glass),
|
||||||
|
description = stringResource(Res.string.settings_appearance_liquid_glass_description),
|
||||||
|
checked = liquidGlassNativeTabBarEnabled,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = onLiquidGlassNativeTabBarToggle,
|
||||||
|
)
|
||||||
|
}
|
||||||
SettingsGroupDivider(isTablet = isTablet)
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = stringResource(Res.string.settings_appearance_app_language),
|
title = stringResource(Res.string.settings_appearance_app_language),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
|
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
|
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
||||||
|
|
@ -40,6 +44,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_style_wid
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
|
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
|
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_title
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
|
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
upNextFromFurthestEpisode: Boolean,
|
upNextFromFurthestEpisode: Boolean,
|
||||||
|
useEpisodeThumbnails: Boolean,
|
||||||
|
showUnairedNextUp: Boolean,
|
||||||
|
blurNextUp: Boolean,
|
||||||
showResumePromptOnLaunch: Boolean,
|
showResumePromptOnLaunch: Boolean,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title),
|
||||||
|
description = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description),
|
||||||
|
checked = useEpisodeThumbnails,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = ContinueWatchingPreferencesRepository::setUseEpisodeThumbnails,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
SettingsSwitchRow(
|
SettingsSwitchRow(
|
||||||
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
title = stringResource(Res.string.settings_continue_watching_up_next_title),
|
||||||
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
description = stringResource(Res.string.settings_continue_watching_up_next_description),
|
||||||
|
|
@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title),
|
||||||
|
description = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description),
|
||||||
|
checked = showUnairedNextUp,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = ContinueWatchingPreferencesRepository::setShowUnairedNextUp,
|
||||||
|
)
|
||||||
|
if (useEpisodeThumbnails) {
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_continue_watching_blur_next_up_title),
|
||||||
|
description = stringResource(Res.string.settings_continue_watching_blur_next_up_description),
|
||||||
|
checked = blurNextUp,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.action_reset
|
import nuvio.composeapp.generated.resources.action_reset
|
||||||
|
import nuvio.composeapp.generated.resources.layout_hide_unreleased
|
||||||
|
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
|
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
|
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
||||||
|
|
@ -62,6 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
internal fun LazyListScope.homescreenSettingsContent(
|
internal fun LazyListScope.homescreenSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
heroEnabled: Boolean,
|
heroEnabled: Boolean,
|
||||||
|
hideUnreleasedContent: Boolean,
|
||||||
items: List<HomeCatalogSettingsItem>,
|
items: List<HomeCatalogSettingsItem>,
|
||||||
) {
|
) {
|
||||||
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
||||||
|
|
@ -87,6 +90,14 @@ internal fun LazyListScope.homescreenSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled,
|
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.layout_hide_unreleased),
|
||||||
|
description = stringResource(Res.string.layout_hide_unreleased_sub),
|
||||||
|
checked = hideUnreleasedContent,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ import nuvio.composeapp.generated.resources.settings_meta_episode_style_list
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description
|
import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_episodes
|
import nuvio.composeapp.generated.resources.settings_meta_episodes
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
|
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
|
||||||
|
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes
|
||||||
|
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_group_label
|
import nuvio.composeapp.generated.resources.settings_meta_group_label
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
|
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
|
||||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
||||||
|
|
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
||||||
selectedStyle = uiState.episodeCardStyle,
|
selectedStyle = uiState.episodeCardStyle,
|
||||||
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
|
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_meta_blur_unwatched_episodes),
|
||||||
|
description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description),
|
||||||
|
checked = uiState.blurUnwatchedEpisodes,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@ fun HomescreenSettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
val homescreenSettingsUiState by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -74,6 +77,7 @@ fun HomescreenSettingsScreen(
|
||||||
homescreenSettingsContent(
|
homescreenSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
|
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
items = homescreenSettingsUiState.items,
|
items = homescreenSettingsUiState.items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +131,9 @@ fun ContinueWatchingSettingsScreen(
|
||||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||||
style = continueWatchingPreferencesUiState.style,
|
style = continueWatchingPreferencesUiState.style,
|
||||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,11 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.max
|
import androidx.compose.ui.unit.max
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.AppTheme
|
import com.nuvio.app.core.ui.AppTheme
|
||||||
|
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||||
|
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
||||||
|
|
@ -56,6 +58,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthUiState
|
import com.nuvio.app.features.trakt.TraktAuthUiState
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
import com.nuvio.app.features.trakt.TraktCommentsSettings
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsUiState
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettings
|
import com.nuvio.app.features.tmdb.TmdbSettings
|
||||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
|
|
@ -92,6 +96,10 @@ fun SettingsScreen(
|
||||||
ThemeSettingsRepository.selectedTheme
|
ThemeSettingsRepository.selectedTheme
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
||||||
|
val liquidGlassNativeTabBarEnabled by remember {
|
||||||
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||||
val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
|
val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle()
|
||||||
val tmdbSettings by remember {
|
val tmdbSettings by remember {
|
||||||
TmdbSettingsRepository.ensureLoaded()
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
|
|
@ -109,6 +117,10 @@ fun SettingsScreen(
|
||||||
TraktCommentsSettings.ensureLoaded()
|
TraktCommentsSettings.ensureLoaded()
|
||||||
TraktCommentsSettings.enabled
|
TraktCommentsSettings.enabled
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val traktSettingsUiState by remember {
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
val addonsUiState by remember {
|
val addonsUiState by remember {
|
||||||
AddonRepository.initialize()
|
AddonRepository.initialize()
|
||||||
AddonRepository.uiState
|
AddonRepository.uiState
|
||||||
|
|
@ -129,6 +141,7 @@ fun SettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val homescreenSettingsUiState by remember {
|
val homescreenSettingsUiState by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
HomeCatalogSettingsRepository.uiState
|
HomeCatalogSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val metaScreenSettingsUiState by remember {
|
val metaScreenSettingsUiState by remember {
|
||||||
|
|
@ -184,6 +197,9 @@ fun SettingsScreen(
|
||||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||||
|
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
|
||||||
selectedAppLanguage = selectedAppLanguage,
|
selectedAppLanguage = selectedAppLanguage,
|
||||||
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
|
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
|
||||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||||
|
|
@ -191,7 +207,9 @@ fun SettingsScreen(
|
||||||
mdbListSettings = mdbListSettings,
|
mdbListSettings = mdbListSettings,
|
||||||
traktAuthUiState = traktAuthUiState,
|
traktAuthUiState = traktAuthUiState,
|
||||||
traktCommentsEnabled = traktCommentsEnabled,
|
traktCommentsEnabled = traktCommentsEnabled,
|
||||||
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
|
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
homescreenItems = homescreenSettingsUiState.items,
|
homescreenItems = homescreenSettingsUiState.items,
|
||||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||||
|
|
@ -224,6 +242,9 @@ fun SettingsScreen(
|
||||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||||
|
onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar,
|
||||||
selectedAppLanguage = selectedAppLanguage,
|
selectedAppLanguage = selectedAppLanguage,
|
||||||
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
|
onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage,
|
||||||
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
|
||||||
|
|
@ -231,7 +252,9 @@ fun SettingsScreen(
|
||||||
mdbListSettings = mdbListSettings,
|
mdbListSettings = mdbListSettings,
|
||||||
traktAuthUiState = traktAuthUiState,
|
traktAuthUiState = traktAuthUiState,
|
||||||
traktCommentsEnabled = traktCommentsEnabled,
|
traktCommentsEnabled = traktCommentsEnabled,
|
||||||
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
|
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
homescreenItems = homescreenSettingsUiState.items,
|
homescreenItems = homescreenSettingsUiState.items,
|
||||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||||
|
|
@ -274,6 +297,9 @@ private fun MobileSettingsScreen(
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
onAmoledToggle: (Boolean) -> Unit,
|
onAmoledToggle: (Boolean) -> Unit,
|
||||||
|
liquidGlassNativeTabBarSupported: Boolean,
|
||||||
|
liquidGlassNativeTabBarEnabled: Boolean,
|
||||||
|
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
|
||||||
selectedAppLanguage: AppLanguage,
|
selectedAppLanguage: AppLanguage,
|
||||||
onAppLanguageSelected: (AppLanguage) -> Unit,
|
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||||
|
|
@ -281,7 +307,9 @@ private fun MobileSettingsScreen(
|
||||||
mdbListSettings: MdbListSettings,
|
mdbListSettings: MdbListSettings,
|
||||||
traktAuthUiState: TraktAuthUiState,
|
traktAuthUiState: TraktAuthUiState,
|
||||||
traktCommentsEnabled: Boolean,
|
traktCommentsEnabled: Boolean,
|
||||||
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
homescreenHeroEnabled: Boolean,
|
homescreenHeroEnabled: Boolean,
|
||||||
|
homescreenHideUnreleasedContent: Boolean,
|
||||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||||
|
|
@ -353,6 +381,9 @@ private fun MobileSettingsScreen(
|
||||||
onThemeSelected = onThemeSelected,
|
onThemeSelected = onThemeSelected,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
onAmoledToggle = onAmoledToggle,
|
onAmoledToggle = onAmoledToggle,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||||
|
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
|
||||||
selectedAppLanguage = selectedAppLanguage,
|
selectedAppLanguage = selectedAppLanguage,
|
||||||
onAppLanguageSelected = onAppLanguageSelected,
|
onAppLanguageSelected = onAppLanguageSelected,
|
||||||
onContinueWatchingClick = onContinueWatchingClick,
|
onContinueWatchingClick = onContinueWatchingClick,
|
||||||
|
|
@ -367,6 +398,9 @@ private fun MobileSettingsScreen(
|
||||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||||
style = continueWatchingPreferencesUiState.style,
|
style = continueWatchingPreferencesUiState.style,
|
||||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||||
|
|
@ -387,6 +421,7 @@ private fun MobileSettingsScreen(
|
||||||
SettingsPage.Homescreen -> homescreenSettingsContent(
|
SettingsPage.Homescreen -> homescreenSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
heroEnabled = homescreenHeroEnabled,
|
heroEnabled = homescreenHeroEnabled,
|
||||||
|
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||||
items = homescreenItems,
|
items = homescreenItems,
|
||||||
)
|
)
|
||||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||||
|
|
@ -409,6 +444,7 @@ private fun MobileSettingsScreen(
|
||||||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
uiState = traktAuthUiState,
|
uiState = traktAuthUiState,
|
||||||
|
settingsUiState = traktSettingsUiState,
|
||||||
commentsEnabled = traktCommentsEnabled,
|
commentsEnabled = traktCommentsEnabled,
|
||||||
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
||||||
)
|
)
|
||||||
|
|
@ -439,6 +475,9 @@ private fun TabletSettingsScreen(
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
onAmoledToggle: (Boolean) -> Unit,
|
onAmoledToggle: (Boolean) -> Unit,
|
||||||
|
liquidGlassNativeTabBarSupported: Boolean,
|
||||||
|
liquidGlassNativeTabBarEnabled: Boolean,
|
||||||
|
onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit,
|
||||||
selectedAppLanguage: AppLanguage,
|
selectedAppLanguage: AppLanguage,
|
||||||
onAppLanguageSelected: (AppLanguage) -> Unit,
|
onAppLanguageSelected: (AppLanguage) -> Unit,
|
||||||
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
|
||||||
|
|
@ -446,7 +485,9 @@ private fun TabletSettingsScreen(
|
||||||
mdbListSettings: MdbListSettings,
|
mdbListSettings: MdbListSettings,
|
||||||
traktAuthUiState: TraktAuthUiState,
|
traktAuthUiState: TraktAuthUiState,
|
||||||
traktCommentsEnabled: Boolean,
|
traktCommentsEnabled: Boolean,
|
||||||
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
homescreenHeroEnabled: Boolean,
|
homescreenHeroEnabled: Boolean,
|
||||||
|
homescreenHideUnreleasedContent: Boolean,
|
||||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||||
|
|
@ -519,6 +560,7 @@ private fun TabletSettingsScreen(
|
||||||
|
|
||||||
saveableStateHolder.SaveableStateProvider(page.name) {
|
saveableStateHolder.SaveableStateProvider(page.name) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -526,7 +568,7 @@ private fun TabletSettingsScreen(
|
||||||
start = 40.dp,
|
start = 40.dp,
|
||||||
top = topOffset,
|
top = topOffset,
|
||||||
end = 40.dp,
|
end = 40.dp,
|
||||||
bottom = 40.dp,
|
bottom = 40.dp + bottomOverlayPadding,
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
) {
|
) {
|
||||||
|
|
@ -589,6 +631,9 @@ private fun TabletSettingsScreen(
|
||||||
onThemeSelected = onThemeSelected,
|
onThemeSelected = onThemeSelected,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
onAmoledToggle = onAmoledToggle,
|
onAmoledToggle = onAmoledToggle,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled,
|
||||||
|
onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle,
|
||||||
selectedAppLanguage = selectedAppLanguage,
|
selectedAppLanguage = selectedAppLanguage,
|
||||||
onAppLanguageSelected = onAppLanguageSelected,
|
onAppLanguageSelected = onAppLanguageSelected,
|
||||||
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
|
onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) },
|
||||||
|
|
@ -603,6 +648,9 @@ private fun TabletSettingsScreen(
|
||||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||||
style = continueWatchingPreferencesUiState.style,
|
style = continueWatchingPreferencesUiState.style,
|
||||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||||
|
|
@ -623,6 +671,7 @@ private fun TabletSettingsScreen(
|
||||||
SettingsPage.Homescreen -> homescreenSettingsContent(
|
SettingsPage.Homescreen -> homescreenSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
heroEnabled = homescreenHeroEnabled,
|
heroEnabled = homescreenHeroEnabled,
|
||||||
|
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||||
items = homescreenItems,
|
items = homescreenItems,
|
||||||
)
|
)
|
||||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||||
|
|
@ -645,6 +694,7 @@ private fun TabletSettingsScreen(
|
||||||
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
SettingsPage.TraktAuthentication -> traktSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
uiState = traktAuthUiState,
|
uiState = traktAuthUiState,
|
||||||
|
settingsUiState = traktSettingsUiState,
|
||||||
commentsEnabled = traktCommentsEnabled,
|
commentsEnabled = traktCommentsEnabled,
|
||||||
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.nuvio.app.features.settings
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
import com.nuvio.app.core.ui.AppTheme
|
import com.nuvio.app.core.ui.AppTheme
|
||||||
|
import com.nuvio.app.core.ui.NativeTabBridge
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -12,6 +13,9 @@ object ThemeSettingsRepository {
|
||||||
private val _amoledEnabled = MutableStateFlow(false)
|
private val _amoledEnabled = MutableStateFlow(false)
|
||||||
val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow()
|
val amoledEnabled: StateFlow<Boolean> = _amoledEnabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
|
||||||
|
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
|
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
|
||||||
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
|
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -30,6 +34,9 @@ object ThemeSettingsRepository {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
_selectedTheme.value = AppTheme.WHITE
|
_selectedTheme.value = AppTheme.WHITE
|
||||||
_amoledEnabled.value = false
|
_amoledEnabled.value = false
|
||||||
|
_liquidGlassNativeTabBarEnabled.value = false
|
||||||
|
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
|
||||||
|
NativeTabBridge.publishLiquidGlassEnabled(false)
|
||||||
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +53,11 @@ object ThemeSettingsRepository {
|
||||||
AppTheme.WHITE
|
AppTheme.WHITE
|
||||||
}
|
}
|
||||||
_selectedTheme.value = theme
|
_selectedTheme.value = theme
|
||||||
|
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||||
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
|
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
|
||||||
|
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
|
||||||
|
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
|
||||||
|
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
|
||||||
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
|
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
|
||||||
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
|
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
|
||||||
_selectedAppLanguage.value = appLanguage
|
_selectedAppLanguage.value = appLanguage
|
||||||
|
|
@ -57,6 +68,7 @@ object ThemeSettingsRepository {
|
||||||
if (_selectedTheme.value == theme) return
|
if (_selectedTheme.value == theme) return
|
||||||
_selectedTheme.value = theme
|
_selectedTheme.value = theme
|
||||||
ThemeSettingsStorage.saveSelectedTheme(theme.name)
|
ThemeSettingsStorage.saveSelectedTheme(theme.name)
|
||||||
|
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAmoled(enabled: Boolean) {
|
fun setAmoled(enabled: Boolean) {
|
||||||
|
|
@ -66,6 +78,14 @@ object ThemeSettingsRepository {
|
||||||
ThemeSettingsStorage.saveAmoledEnabled(enabled)
|
ThemeSettingsStorage.saveAmoledEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setLiquidGlassNativeTabBar(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (_liquidGlassNativeTabBarEnabled.value == enabled) return
|
||||||
|
_liquidGlassNativeTabBarEnabled.value = enabled
|
||||||
|
ThemeSettingsStorage.saveLiquidGlassNativeTabBarEnabled(enabled)
|
||||||
|
NativeTabBridge.publishLiquidGlassEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
fun setAppLanguage(language: AppLanguage) {
|
fun setAppLanguage(language: AppLanguage) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
if (_selectedAppLanguage.value == language) return
|
if (_selectedAppLanguage.value == language) return
|
||||||
|
|
@ -74,3 +94,13 @@ object ThemeSettingsRepository {
|
||||||
_selectedAppLanguage.value = language
|
_selectedAppLanguage.value = language
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AppTheme.nativeTabAccentHex(): String = when (this) {
|
||||||
|
AppTheme.CRIMSON -> "#E53935"
|
||||||
|
AppTheme.OCEAN -> "#1E88E5"
|
||||||
|
AppTheme.VIOLET -> "#8E24AA"
|
||||||
|
AppTheme.EMERALD -> "#43A047"
|
||||||
|
AppTheme.AMBER -> "#FB8C00"
|
||||||
|
AppTheme.ROSE -> "#D81B60"
|
||||||
|
AppTheme.WHITE -> "#F5F5F5"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ internal expect object ThemeSettingsStorage {
|
||||||
fun saveSelectedTheme(themeName: String)
|
fun saveSelectedTheme(themeName: String)
|
||||||
fun loadAmoledEnabled(): Boolean?
|
fun loadAmoledEnabled(): Boolean?
|
||||||
fun saveAmoledEnabled(enabled: Boolean)
|
fun saveAmoledEnabled(enabled: Boolean)
|
||||||
|
fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
|
||||||
|
fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
|
||||||
fun loadSelectedAppLanguage(): String?
|
fun loadSelectedAppLanguage(): String?
|
||||||
fun saveSelectedAppLanguage(languageCode: String)
|
fun saveSelectedAppLanguage(languageCode: String)
|
||||||
fun applySelectedAppLanguage(languageCode: String)
|
fun applySelectedAppLanguage(languageCode: String)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,57 @@
|
||||||
package com.nuvio.app.features.settings
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.nuvio.app.features.library.LibrarySourceMode
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktBrandAsset
|
import com.nuvio.app.features.trakt.TraktBrandAsset
|
||||||
import com.nuvio.app.features.trakt.TraktAuthUiState
|
import com.nuvio.app.features.trakt.TraktAuthUiState
|
||||||
import com.nuvio.app.features.trakt.TraktConnectionMode
|
import com.nuvio.app.features.trakt.TraktConnectionMode
|
||||||
|
import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsUiState
|
||||||
|
import com.nuvio.app.features.trakt.WatchProgressSource
|
||||||
|
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||||
|
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
|
||||||
import com.nuvio.app.features.trakt.traktBrandPainter
|
import com.nuvio.app.features.trakt.traktBrandPainter
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.action_cancel
|
import nuvio.composeapp.generated.resources.action_cancel
|
||||||
|
import nuvio.composeapp.generated.resources.settings_playback_dialog_close
|
||||||
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
|
import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect
|
||||||
import nuvio.composeapp.generated.resources.settings_trakt_authentication
|
import nuvio.composeapp.generated.resources.settings_trakt_authentication
|
||||||
import nuvio.composeapp.generated.resources.settings_trakt_comments
|
import nuvio.composeapp.generated.resources.settings_trakt_comments
|
||||||
|
|
@ -42,11 +68,34 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials
|
||||||
import nuvio.composeapp.generated.resources.settings_trakt_open_login
|
import nuvio.composeapp.generated.resources.settings_trakt_open_login
|
||||||
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
|
import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description
|
||||||
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
|
import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_all_history
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_continue_watching_window
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_cw_window_title
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_days_format
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_title
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_trakt
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_title
|
||||||
|
import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.traktSettingsContent(
|
internal fun LazyListScope.traktSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
uiState: TraktAuthUiState,
|
uiState: TraktAuthUiState,
|
||||||
|
settingsUiState: TraktSettingsUiState,
|
||||||
commentsEnabled: Boolean,
|
commentsEnabled: Boolean,
|
||||||
onCommentsEnabledChange: (Boolean) -> Unit,
|
onCommentsEnabledChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -77,12 +126,414 @@ internal fun LazyListScope.traktSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
) {
|
) {
|
||||||
SettingsGroup(isTablet = isTablet) {
|
SettingsGroup(isTablet = isTablet) {
|
||||||
SettingsSwitchRow(
|
TraktFeatureRows(
|
||||||
title = stringResource(Res.string.settings_trakt_comments),
|
|
||||||
description = stringResource(Res.string.settings_trakt_comments_description),
|
|
||||||
checked = commentsEnabled,
|
|
||||||
isTablet = isTablet,
|
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,
|
posterShape = PosterShape.Poster,
|
||||||
description = recommendation.overview?.trim()?.takeIf(String::isNotBlank),
|
description = recommendation.overview?.trim()?.takeIf(String::isNotBlank),
|
||||||
releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4),
|
releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4),
|
||||||
|
rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate,
|
||||||
imdbRating = recommendation.voteAverage?.formatRating(),
|
imdbRating = recommendation.voteAverage?.formatRating(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1087,6 +1088,7 @@ object TmdbMetadataService {
|
||||||
posterShape = PosterShape.Landscape,
|
posterShape = PosterShape.Landscape,
|
||||||
description = part.overview?.trim()?.takeIf(String::isNotBlank),
|
description = part.overview?.trim()?.takeIf(String::isNotBlank),
|
||||||
releaseInfo = part.releaseDate?.take(4),
|
releaseInfo = part.releaseDate?.take(4),
|
||||||
|
rawReleaseDate = part.releaseDate,
|
||||||
imdbRating = part.voteAverage?.formatRating(),
|
imdbRating = part.voteAverage?.formatRating(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ private const val LIST_FETCH_CONCURRENCY = 4
|
||||||
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
||||||
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
|
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
|
||||||
private const val FORCE_REFRESH_DEDUP_MS = 10_000L
|
private const val FORCE_REFRESH_DEDUP_MS = 10_000L
|
||||||
|
private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240
|
||||||
|
|
||||||
data class TraktLibraryUiState(
|
data class TraktLibraryUiState(
|
||||||
val listTabs: List<TraktListTab> = emptyList(),
|
val listTabs: List<TraktListTab> = emptyList(),
|
||||||
|
|
@ -159,21 +160,20 @@ object TraktLibraryRepository {
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}
|
||||||
|
result.exceptionOrNull()?.let { error ->
|
||||||
if (error is CancellationException) throw error
|
if (error is CancellationException) throw error
|
||||||
log.w { "Failed to refresh Trakt library: ${error.message}" }
|
log.w(error) { "Failed to refresh Trakt library" }
|
||||||
}.getOrNull()
|
_uiState.value = _uiState.value.copy(
|
||||||
|
|
||||||
if (result == null) {
|
|
||||||
_uiState.value = current.copy(
|
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
hasLoaded = true,
|
hasLoaded = true,
|
||||||
errorMessage = getString(Res.string.trakt_library_load_failed),
|
errorMessage = traktLibraryLoadErrorMessage(error),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.value = result.copy(
|
val snapshot = result.getOrThrow()
|
||||||
|
_uiState.value = snapshot.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
hasLoaded = true,
|
hasLoaded = true,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
|
@ -414,6 +414,27 @@ object TraktLibraryRepository {
|
||||||
TraktLibraryStorage.savePayload(json.encodeToString(payload))
|
TraktLibraryStorage.savePayload(json.encodeToString(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun traktLibraryLoadErrorMessage(error: Throwable): String {
|
||||||
|
val fallback = getString(Res.string.trakt_library_load_failed)
|
||||||
|
val detail = error.userVisibleMessage()
|
||||||
|
return when {
|
||||||
|
detail.isBlank() -> fallback
|
||||||
|
detail.equals(fallback, ignoreCase = true) -> fallback
|
||||||
|
else -> detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.userVisibleMessage(): String {
|
||||||
|
val raw = message?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
?: toString().trim()
|
||||||
|
val firstLine = raw.lines().firstOrNull()?.trim().orEmpty()
|
||||||
|
return if (firstLine.length <= MAX_VISIBLE_ERROR_MESSAGE_LENGTH) {
|
||||||
|
firstLine
|
||||||
|
} else {
|
||||||
|
firstLine.take(MAX_VISIBLE_ERROR_MESSAGE_LENGTH).trimEnd() + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
|
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
|
||||||
val watchlistTabs = listOf(
|
val watchlistTabs = listOf(
|
||||||
TraktListTab(
|
TraktListTab(
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,13 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||||
import com.nuvio.app.features.addons.httpRequestRaw
|
import com.nuvio.app.features.addons.httpRequestRaw
|
||||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||||
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
|
||||||
|
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
|
||||||
|
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
|
||||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||||
|
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
private const val BASE_URL = "https://api.trakt.tv"
|
private const val BASE_URL = "https://api.trakt.tv"
|
||||||
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 80f
|
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f
|
||||||
private const val HISTORY_LIMIT = 250
|
private const val HISTORY_LIMIT = 250
|
||||||
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
|
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
|
||||||
private const val METADATA_FETCH_CONCURRENCY = 5
|
private const val METADATA_FETCH_CONCURRENCY = 5
|
||||||
|
|
@ -113,8 +118,8 @@ object TraktProgressRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val historyEntries = runCatching {
|
val completedEntries = runCatching {
|
||||||
fetchHistoryEntries(headers)
|
fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
if (error is CancellationException) throw error
|
if (error is CancellationException) throw error
|
||||||
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
|
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
|
||||||
|
|
@ -122,7 +127,7 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
if (!isLatestRefreshRequest(requestId)) return@launch
|
if (!isLatestRefreshRequest(requestId)) return@launch
|
||||||
|
|
||||||
val merged = mergeNewestByVideoId(playbackEntries + historyEntries)
|
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
|
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -345,12 +350,32 @@ object TraktProgressRepository {
|
||||||
mergeNewestByVideoId(completedEpisodes + completedMovies)
|
mergeNewestByVideoId(completedEpisodes + completedMovies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchWatchedShowSeedEntries(
|
||||||
|
headers: Map<String, String>,
|
||||||
|
): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
|
||||||
|
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||||
|
val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode
|
||||||
|
val payload = httpGetTextWithHeaders(
|
||||||
|
url = "$BASE_URL/sync/watched/shows",
|
||||||
|
headers = headers,
|
||||||
|
)
|
||||||
|
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
|
||||||
|
watchedShows
|
||||||
|
.mapNotNull { item ->
|
||||||
|
mapWatchedShowSeed(
|
||||||
|
item = item,
|
||||||
|
useFurthestEpisode = useFurthestEpisode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
||||||
|
}
|
||||||
|
|
||||||
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
|
||||||
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
|
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
|
||||||
entries.forEach { rawEntry ->
|
entries.forEach { rawEntry ->
|
||||||
val entry = rawEntry.normalizedCompletion()
|
val entry = rawEntry.normalizedCompletion()
|
||||||
val existing = mergedByVideoId[entry.videoId]
|
val existing = mergedByVideoId[entry.videoId]
|
||||||
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
|
if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
|
||||||
mergedByVideoId[entry.videoId] = entry
|
mergedByVideoId[entry.videoId] = entry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -360,6 +385,18 @@ object TraktProgressRepository {
|
||||||
.sortedByDescending { it.lastUpdatedEpochMs }
|
.sortedByDescending { it.lastUpdatedEpochMs }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shouldReplaceProgressSnapshotEntry(
|
||||||
|
existing: WatchProgressEntry,
|
||||||
|
candidate: WatchProgressEntry,
|
||||||
|
): Boolean {
|
||||||
|
val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching()
|
||||||
|
val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching()
|
||||||
|
if (existingInProgress != candidateInProgress) {
|
||||||
|
return candidateInProgress
|
||||||
|
}
|
||||||
|
return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs
|
||||||
|
}
|
||||||
|
|
||||||
private fun mergeEntriesPreferRichMetadata(
|
private fun mergeEntriesPreferRichMetadata(
|
||||||
current: List<WatchProgressEntry>,
|
current: List<WatchProgressEntry>,
|
||||||
hydrated: List<WatchProgressEntry>,
|
hydrated: List<WatchProgressEntry>,
|
||||||
|
|
@ -499,6 +536,7 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||||
progressPercent = progressPercent,
|
progressPercent = progressPercent,
|
||||||
|
source = WatchProgressSourceTraktPlayback,
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -533,6 +571,7 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||||
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
|
||||||
progressPercent = progressPercent,
|
progressPercent = progressPercent,
|
||||||
|
source = WatchProgressSourceTraktPlayback,
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,6 +603,7 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||||
isCompleted = true,
|
isCompleted = true,
|
||||||
progressPercent = 100f,
|
progressPercent = 100f,
|
||||||
|
source = WatchProgressSourceTraktHistory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,6 +623,73 @@ object TraktProgressRepository {
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||||
isCompleted = true,
|
isCompleted = true,
|
||||||
progressPercent = 100f,
|
progressPercent = 100f,
|
||||||
|
source = WatchProgressSourceTraktHistory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapWatchedShowSeed(
|
||||||
|
item: TraktWatchedShowItem,
|
||||||
|
useFurthestEpisode: Boolean,
|
||||||
|
): WatchProgressEntry? {
|
||||||
|
val show = item.show ?: return null
|
||||||
|
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
||||||
|
if (parentMetaId.isBlank()) return null
|
||||||
|
|
||||||
|
val completedEpisode = item.seasons.orEmpty()
|
||||||
|
.asSequence()
|
||||||
|
.filter { season -> (season.number ?: 0) > 0 }
|
||||||
|
.flatMap { season ->
|
||||||
|
val seasonNumber = season.number ?: return@flatMap emptySequence()
|
||||||
|
season.episodes.orEmpty()
|
||||||
|
.asSequence()
|
||||||
|
.filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 }
|
||||||
|
.mapNotNull { episode ->
|
||||||
|
val episodeNumber = episode.number ?: return@mapNotNull null
|
||||||
|
TraktWatchedShowEpisodeSeed(
|
||||||
|
season = seasonNumber,
|
||||||
|
episode = episodeNumber,
|
||||||
|
watchedAt = rankedTimestamp(
|
||||||
|
isoDate = episode.lastWatchedAt ?: item.lastWatchedAt,
|
||||||
|
fallbackIndex = 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.maxWithOrNull(
|
||||||
|
if (useFurthestEpisode) {
|
||||||
|
compareBy<TraktWatchedShowEpisodeSeed>(
|
||||||
|
{ it.season },
|
||||||
|
{ it.episode },
|
||||||
|
{ it.watchedAt },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
compareBy<TraktWatchedShowEpisodeSeed>(
|
||||||
|
{ it.watchedAt },
|
||||||
|
{ it.season },
|
||||||
|
{ it.episode },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
return WatchProgressEntry(
|
||||||
|
contentType = "series",
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
parentMetaType = "series",
|
||||||
|
videoId = buildPlaybackVideoId(
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
seasonNumber = completedEpisode.season,
|
||||||
|
episodeNumber = completedEpisode.episode,
|
||||||
|
fallbackVideoId = null,
|
||||||
|
),
|
||||||
|
title = show.title ?: parentMetaId,
|
||||||
|
seasonNumber = completedEpisode.season,
|
||||||
|
episodeNumber = completedEpisode.episode,
|
||||||
|
lastPositionMs = 1L,
|
||||||
|
durationMs = 1L,
|
||||||
|
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
||||||
|
isCompleted = true,
|
||||||
|
progressPercent = 100f,
|
||||||
|
source = WatchProgressSourceTraktShowProgress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -597,14 +704,10 @@ object TraktProgressRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
|
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
|
||||||
val compactDigits = isoDate
|
isoDate
|
||||||
?.filter(Char::isDigit)
|
?.takeIf { it.isNotBlank() }
|
||||||
?.take(14)
|
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||||
?.takeIf { it.length >= 8 }
|
?.let { return it }
|
||||||
?.padEnd(14, '0')
|
|
||||||
?.toLongOrNull()
|
|
||||||
if (compactDigits != null) return compactDigits
|
|
||||||
|
|
||||||
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
|
||||||
@SerialName("movie") val movie: TraktMedia? = null,
|
@SerialName("movie") val movie: TraktMedia? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TraktWatchedShowItem(
|
||||||
|
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
|
||||||
|
@SerialName("show") val show: TraktMedia? = null,
|
||||||
|
@SerialName("seasons") val seasons: List<TraktWatchedShowSeason>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TraktWatchedShowSeason(
|
||||||
|
@SerialName("number") val number: Int? = null,
|
||||||
|
@SerialName("episodes") val episodes: List<TraktWatchedShowEpisode>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TraktWatchedShowEpisode(
|
||||||
|
@SerialName("number") val number: Int? = null,
|
||||||
|
@SerialName("plays") val plays: Int? = null,
|
||||||
|
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class TraktWatchedShowEpisodeSeed(
|
||||||
|
val season: Int,
|
||||||
|
val episode: Int,
|
||||||
|
val watchedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class TraktMedia(
|
private data class TraktMedia(
|
||||||
@SerialName("title") val title: String? = null,
|
@SerialName("title") val title: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.nuvio.app.features.watched
|
||||||
|
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import com.nuvio.app.features.watching.domain.watchedKey
|
import com.nuvio.app.features.watching.domain.watchedKey
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
|
||||||
val WatchedItem.isEpisode: Boolean
|
val WatchedItem.isEpisode: Boolean
|
||||||
get() = season != null && episode != null
|
get() = season != null && episode != null
|
||||||
|
|
||||||
|
internal fun WatchedItem.normalizedMarkedAt(): WatchedItem {
|
||||||
|
val normalized = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs)
|
||||||
|
return if (normalized == markedAtEpochMs) this else copy(markedAtEpochMs = normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long {
|
||||||
|
if (value !in CompactWatchedTimestampMin..CompactWatchedTimestampMax) return value
|
||||||
|
|
||||||
|
val raw = value.toString().padStart(14, '0')
|
||||||
|
val year = raw.substring(0, 4).toIntOrNull() ?: return value
|
||||||
|
val month = raw.substring(4, 6).toIntOrNull() ?: return value
|
||||||
|
val day = raw.substring(6, 8).toIntOrNull() ?: return value
|
||||||
|
val hour = raw.substring(8, 10).toIntOrNull() ?: return value
|
||||||
|
val minute = raw.substring(10, 12).toIntOrNull() ?: return value
|
||||||
|
val second = raw.substring(12, 14).toIntOrNull() ?: return value
|
||||||
|
|
||||||
|
if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
val iso = buildString {
|
||||||
|
append(year.toString().padStart(4, '0'))
|
||||||
|
append('-')
|
||||||
|
append(month.toString().padStart(2, '0'))
|
||||||
|
append('-')
|
||||||
|
append(day.toString().padStart(2, '0'))
|
||||||
|
append('T')
|
||||||
|
append(hour.toString().padStart(2, '0'))
|
||||||
|
append(':')
|
||||||
|
append(minute.toString().padStart(2, '0'))
|
||||||
|
append(':')
|
||||||
|
append(second.toString().padStart(2, '0'))
|
||||||
|
append('Z')
|
||||||
|
}
|
||||||
|
return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value
|
||||||
|
}
|
||||||
|
|
||||||
fun watchedItemKey(
|
fun watchedItemKey(
|
||||||
type: String,
|
type: String,
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -47,3 +85,5 @@ fun watchedItemKey(
|
||||||
episodeNumber = episode,
|
episodeNumber = episode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private const val CompactWatchedTimestampMin = 19000101000000L
|
||||||
|
private const val CompactWatchedTimestampMax = 29991231235959L
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.details.MetaDetails
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
|
import com.nuvio.app.features.trakt.WatchProgressSource
|
||||||
|
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||||
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
|
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
|
||||||
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
|
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
|
||||||
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
|
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
|
||||||
|
|
@ -42,8 +45,8 @@ object WatchedRepository {
|
||||||
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
|
||||||
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
|
||||||
|
|
||||||
private fun activeSyncAdapter(): WatchedSyncAdapter =
|
private fun activePullSyncAdapter(): WatchedSyncAdapter =
|
||||||
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter
|
if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
if (hasLoaded) return
|
if (hasLoaded) return
|
||||||
|
|
@ -72,21 +75,27 @@ object WatchedRepository {
|
||||||
val items = runCatching {
|
val items = runCatching {
|
||||||
json.decodeFromString<StoredWatchedPayload>(payload).items
|
json.decodeFromString<StoredWatchedPayload>(payload).items
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
itemsByKey = items.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap()
|
itemsByKey = items
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||||
|
.toMutableMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun pullFromServer(profileId: Int) {
|
suspend fun pullFromServer(profileId: Int) {
|
||||||
|
TraktAuthRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
currentProfileId = profileId
|
currentProfileId = profileId
|
||||||
runCatching {
|
runCatching {
|
||||||
val serverItems = activeSyncAdapter().pull(
|
val serverItems = activePullSyncAdapter().pull(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
pageSize = watchedItemsPageSize,
|
pageSize = watchedItemsPageSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
itemsByKey = serverItems
|
itemsByKey = serverItems
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||||
.toMutableMap()
|
.toMutableMap()
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
|
|
@ -203,7 +212,7 @@ object WatchedRepository {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (items.isEmpty()) return@runCatching
|
if (items.isEmpty()) return@runCatching
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
activeSyncAdapter().push(profileId = profileId, items = items)
|
pushToActiveTargets(profileId = profileId, items = items)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to push watched items" }
|
log.e(e) { "Failed to push watched items" }
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +224,7 @@ object WatchedRepository {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (items.isEmpty()) return@runCatching
|
if (items.isEmpty()) return@runCatching
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
activeSyncAdapter().delete(profileId = profileId, items = items)
|
deleteFromActiveTargets(profileId = profileId, items = items)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to push watched item delete" }
|
log.e(e) { "Failed to push watched item delete" }
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +232,9 @@ object WatchedRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }
|
val items = itemsByKey.values
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.sortedByDescending { it.markedAtEpochMs }
|
||||||
_uiState.value = WatchedUiState(
|
_uiState.value = WatchedUiState(
|
||||||
items = items,
|
items = items,
|
||||||
watchedKeys = items.mapTo(linkedSetOf()) {
|
watchedKeys = items.mapTo(linkedSetOf()) {
|
||||||
|
|
@ -238,9 +249,55 @@ object WatchedRepository {
|
||||||
currentProfileId,
|
currentProfileId,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
StoredWatchedPayload(
|
StoredWatchedPayload(
|
||||||
items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs },
|
items = itemsByKey.values
|
||||||
|
.map(WatchedItem::normalizedMarkedAt)
|
||||||
|
.sortedByDescending { it.markedAtEpochMs },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shouldUseTraktWatchedSync(): Boolean =
|
||||||
|
shouldUseTraktWatchedSync(
|
||||||
|
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||||
|
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun pushToActiveTargets(
|
||||||
|
profileId: Int,
|
||||||
|
items: Collection<WatchedItem>,
|
||||||
|
) {
|
||||||
|
if (shouldUseTraktWatchedSync()) {
|
||||||
|
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAdapter.push(profileId = profileId, items = items)
|
||||||
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
|
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteFromActiveTargets(
|
||||||
|
profileId: Int,
|
||||||
|
items: Collection<WatchedItem>,
|
||||||
|
) {
|
||||||
|
if (shouldUseTraktWatchedSync()) {
|
||||||
|
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAdapter.delete(profileId = profileId, items = items)
|
||||||
|
if (TraktAuthRepository.isAuthenticated.value) {
|
||||||
|
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun shouldUseTraktWatchedSync(
|
||||||
|
isAuthenticated: Boolean,
|
||||||
|
source: WatchProgressSource,
|
||||||
|
): Boolean = shouldUseTraktProgress(
|
||||||
|
isAuthenticated = isAuthenticated,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ package com.nuvio.app.features.watching.application
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
|
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||||
import com.nuvio.app.features.watched.watchedItemKey
|
import com.nuvio.app.features.watched.watchedItemKey
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
import com.nuvio.app.features.watchprogress.continueWatchingEntries
|
||||||
|
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
|
||||||
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
|
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
|
||||||
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
|
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
|
||||||
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
|
|
||||||
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
|
||||||
|
|
||||||
object WatchingState {
|
object WatchingState {
|
||||||
|
|
@ -59,7 +61,9 @@ object WatchingState {
|
||||||
add(WatchingContentRef(type = item.type, id = item.id))
|
add(WatchingContentRef(type = item.type, id = item.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord)
|
val progressRecords = progressEntries
|
||||||
|
.filter { entry -> entry.shouldUseAsCompletedSeedForContinueWatching() }
|
||||||
|
.map(WatchProgressEntry::toDomainProgressRecord)
|
||||||
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
|
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
|
||||||
return contentRefs.mapNotNull { content ->
|
return contentRefs.mapNotNull { content ->
|
||||||
latestCompletedSeriesEpisode(
|
latestCompletedSeriesEpisode(
|
||||||
|
|
@ -73,21 +77,9 @@ object WatchingState {
|
||||||
|
|
||||||
fun visibleContinueWatchingEntries(
|
fun visibleContinueWatchingEntries(
|
||||||
progressEntries: List<WatchProgressEntry>,
|
progressEntries: List<WatchProgressEntry>,
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
|
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
|
||||||
): List<WatchProgressEntry> {
|
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
|
||||||
val visibleIds = continueWatchingProgressEntries(
|
|
||||||
progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
|
||||||
)
|
|
||||||
.filter { record ->
|
|
||||||
val latestCompleted = latestCompletedBySeries[record.content]
|
|
||||||
latestCompleted == null || record.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs
|
|
||||||
}
|
|
||||||
.mapTo(linkedSetOf()) { record -> record.videoId }
|
|
||||||
|
|
||||||
return progressEntries
|
|
||||||
.filter { entry -> entry.videoId in visibleIds }
|
|
||||||
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||||
|
|
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
|
||||||
content = WatchingContentRef(type = type, id = id),
|
content = WatchingContentRef(type = type, id = id),
|
||||||
seasonNumber = season,
|
seasonNumber = season,
|
||||||
episodeNumber = episode,
|
episodeNumber = episode,
|
||||||
markedAtEpochMs = markedAtEpochMs,
|
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
||||||
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
|
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
|
||||||
val params = buildJsonObject { put("p_profile_id", profileId) }
|
val params = buildJsonObject { put("p_profile_id", profileId) }
|
||||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
|
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
|
||||||
return result.decodeList<WatchProgressSyncEntry>().map { entry ->
|
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
|
||||||
|
val records = serverEntries.map { entry ->
|
||||||
ProgressSyncRecord(
|
ProgressSyncRecord(
|
||||||
contentId = entry.contentId,
|
contentId = entry.contentId,
|
||||||
contentType = entry.contentType,
|
contentType = entry.contentType,
|
||||||
|
|
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
|
||||||
lastWatched = entry.lastWatched,
|
lastWatched = entry.lastWatched,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun push(
|
override suspend fun push(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.sync
|
||||||
|
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
|
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||||
import io.github.jan.supabase.postgrest.postgrest
|
import io.github.jan.supabase.postgrest.postgrest
|
||||||
import io.github.jan.supabase.postgrest.rpc
|
import io.github.jan.supabase.postgrest.rpc
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
|
|
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
name = syncItem.title,
|
name = syncItem.title,
|
||||||
season = syncItem.season,
|
season = syncItem.season,
|
||||||
episode = syncItem.episode,
|
episode = syncItem.episode,
|
||||||
markedAtEpochMs = syncItem.watchedAt,
|
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
title = item.name,
|
title = item.name,
|
||||||
season = item.season,
|
season = item.season,
|
||||||
episode = item.episode,
|
episode = item.episode,
|
||||||
watchedAt = item.markedAtEpochMs,
|
watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val params = buildJsonObject {
|
val params = buildJsonObject {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
||||||
|
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
|
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rankedTimestamp(isoDate: String?): Long {
|
private fun rankedTimestamp(isoDate: String?): Long {
|
||||||
val digits = isoDate
|
return isoDate
|
||||||
?.filter(Char::isDigit)
|
?.takeIf { it.isNotBlank() }
|
||||||
?.take(14)
|
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
|
||||||
?.takeIf { it.length >= 8 }
|
?: 0L
|
||||||
?.padEnd(14, '0')
|
|
||||||
?.toLongOrNull()
|
|
||||||
return digits ?: 0L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun epochMsToIso(epochMs: Long): String {
|
private fun epochMsToIso(epochMs: Long): String {
|
||||||
// Convert to a compact ISO 8601 UTC string.
|
val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
|
||||||
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places,
|
if (normalizedEpochMs <= 0L) return "unknown"
|
||||||
// or a real epoch-ms. We only send when it looks like real epoch-ms.
|
if (normalizedEpochMs < 10_000_000_000L) return "unknown"
|
||||||
if (epochMs <= 0L) return "unknown"
|
|
||||||
if (epochMs < 10_000_000_000L) {
|
|
||||||
// Looks like seconds-based or ranked timestamp — send unknown
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
// Real epoch ms → simple ISO via arithmetic
|
// Real epoch ms → simple ISO via arithmetic
|
||||||
val totalSeconds = epochMs / 1000
|
val totalSeconds = normalizedEpochMs / 1000
|
||||||
val s = (totalSeconds % 60).toInt()
|
val s = (totalSeconds % 60).toInt()
|
||||||
val m = ((totalSeconds / 60) % 60).toInt()
|
val m = ((totalSeconds / 60) % 60).toInt()
|
||||||
val h = ((totalSeconds / 3600) % 24).toInt()
|
val h = ((totalSeconds / 3600) % 24).toInt()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ data class CachedNextUpItem(
|
||||||
val episodeTitle: String? = null,
|
val episodeTitle: String? = null,
|
||||||
val episodeThumbnail: String? = null,
|
val episodeThumbnail: String? = null,
|
||||||
val pauseDescription: String? = null,
|
val pauseDescription: String? = null,
|
||||||
|
val released: String? = null,
|
||||||
|
val hasAired: Boolean = true,
|
||||||
val lastWatched: Long,
|
val lastWatched: Long,
|
||||||
val sortTimestamp: Long,
|
val sortTimestamp: Long,
|
||||||
val seedSeason: Int? = null,
|
val seedSeason: Int? = null,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
@ -13,6 +14,12 @@ private data class StoredContinueWatchingPreferences(
|
||||||
val isVisible: Boolean = true,
|
val isVisible: Boolean = true,
|
||||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||||
val upNextFromFurthestEpisode: Boolean = true,
|
val upNextFromFurthestEpisode: Boolean = true,
|
||||||
|
@SerialName("use_episode_thumbnails_in_cw")
|
||||||
|
val useEpisodeThumbnails: Boolean = true,
|
||||||
|
@SerialName("show_unaired_next_up")
|
||||||
|
val showUnairedNextUp: Boolean = true,
|
||||||
|
@SerialName("blur_continue_watching_next_up")
|
||||||
|
val blurNextUp: Boolean = false,
|
||||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||||
val showResumePromptOnLaunch: Boolean = true,
|
val showResumePromptOnLaunch: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible: Boolean,
|
isVisible: Boolean,
|
||||||
style: ContinueWatchingSectionStyle,
|
style: ContinueWatchingSectionStyle,
|
||||||
upNextFromFurthestEpisode: Boolean,
|
upNextFromFurthestEpisode: Boolean,
|
||||||
|
useEpisodeThumbnails: Boolean = true,
|
||||||
|
showUnairedNextUp: Boolean = true,
|
||||||
|
blurNextUp: Boolean = false,
|
||||||
dismissedNextUpKeys: Set<String>,
|
dismissedNextUpKeys: Set<String>,
|
||||||
) {
|
) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
|
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible = isVisible,
|
isVisible = isVisible,
|
||||||
style = style,
|
style = style,
|
||||||
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = showUnairedNextUp,
|
||||||
|
blurNextUp = blurNextUp,
|
||||||
dismissedNextUpKeys = dismissedNextUpKeys
|
dismissedNextUpKeys = dismissedNextUpKeys
|
||||||
.map(String::trim)
|
.map(String::trim)
|
||||||
.filter(String::isNotBlank)
|
.filter(String::isNotBlank)
|
||||||
|
|
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible = stored.isVisible,
|
isVisible = stored.isVisible,
|
||||||
style = stored.style,
|
style = stored.style,
|
||||||
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = stored.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = stored.showUnairedNextUp,
|
||||||
|
blurNextUp = stored.blurNextUp,
|
||||||
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
||||||
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
||||||
)
|
)
|
||||||
|
|
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setUseEpisodeThumbnails(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
_uiState.value = _uiState.value.copy(useEpisodeThumbnails = enabled)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowUnairedNextUp(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
_uiState.value = _uiState.value.copy(showUnairedNextUp = enabled)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBlurNextUp(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
_uiState.value = _uiState.value.copy(blurNextUp = enabled)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
fun addDismissedNextUpKey(key: String) {
|
fun addDismissedNextUpKey(key: String) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
val normalizedKey = key.trim()
|
val normalizedKey = key.trim()
|
||||||
|
|
@ -139,6 +173,9 @@ object ContinueWatchingPreferencesRepository {
|
||||||
isVisible = _uiState.value.isVisible,
|
isVisible = _uiState.value.isVisible,
|
||||||
style = _uiState.value.style,
|
style = _uiState.value.style,
|
||||||
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
||||||
|
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
|
||||||
|
showUnairedNextUp = _uiState.value.showUnairedNextUp,
|
||||||
|
blurNextUp = _uiState.value.blurNextUp,
|
||||||
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
||||||
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
import com.nuvio.app.features.watching.domain.WatchingContentRef
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
internal const val WatchProgressCompletionPercentThreshold = 99.5f
|
internal const val WatchProgressCompletionPercentThreshold = 90f
|
||||||
|
internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f
|
||||||
|
internal const val WatchProgressSourceLocal = "local"
|
||||||
|
internal const val WatchProgressSourceTraktPlayback = "trakt_playback"
|
||||||
|
internal const val WatchProgressSourceTraktHistory = "trakt_history"
|
||||||
|
internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress"
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class ContinueWatchingSectionStyle {
|
enum class ContinueWatchingSectionStyle {
|
||||||
|
|
@ -37,6 +42,7 @@ data class WatchProgressEntry(
|
||||||
val lastSourceUrl: String? = null,
|
val lastSourceUrl: String? = null,
|
||||||
val isCompleted: Boolean = false,
|
val isCompleted: Boolean = false,
|
||||||
val progressPercent: Float? = null,
|
val progressPercent: Float? = null,
|
||||||
|
val source: String = WatchProgressSourceLocal,
|
||||||
) {
|
) {
|
||||||
val normalizedProgressPercent: Float?
|
val normalizedProgressPercent: Float?
|
||||||
get() = progressPercent?.coerceIn(0f, 100f)
|
get() = progressPercent?.coerceIn(0f, 100f)
|
||||||
|
|
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
|
||||||
val episodeTitle: String? = null,
|
val episodeTitle: String? = null,
|
||||||
val episodeThumbnail: String? = null,
|
val episodeThumbnail: String? = null,
|
||||||
val pauseDescription: String? = null,
|
val pauseDescription: String? = null,
|
||||||
|
val released: String? = null,
|
||||||
val isNextUp: Boolean = false,
|
val isNextUp: Boolean = false,
|
||||||
val nextUpSeedSeasonNumber: Int? = null,
|
val nextUpSeedSeasonNumber: Int? = null,
|
||||||
val nextUpSeedEpisodeNumber: Int? = null,
|
val nextUpSeedEpisodeNumber: Int? = null,
|
||||||
|
|
@ -163,6 +170,9 @@ data class ContinueWatchingPreferencesUiState(
|
||||||
val isVisible: Boolean = true,
|
val isVisible: Boolean = true,
|
||||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||||
val upNextFromFurthestEpisode: Boolean = true,
|
val upNextFromFurthestEpisode: Boolean = true,
|
||||||
|
val useEpisodeThumbnails: Boolean = true,
|
||||||
|
val showUnairedNextUp: Boolean = true,
|
||||||
|
val blurNextUp: Boolean = false,
|
||||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||||
val showResumePromptOnLaunch: Boolean = true,
|
val showResumePromptOnLaunch: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
|
||||||
episodeTitle = normalizedEntry.episodeTitle,
|
episodeTitle = normalizedEntry.episodeTitle,
|
||||||
episodeThumbnail = normalizedEntry.episodeThumbnail,
|
episodeThumbnail = normalizedEntry.episodeThumbnail,
|
||||||
pauseDescription = normalizedEntry.pauseDescription,
|
pauseDescription = normalizedEntry.pauseDescription,
|
||||||
|
released = null,
|
||||||
isNextUp = false,
|
isNextUp = false,
|
||||||
nextUpSeedSeasonNumber = null,
|
nextUpSeedSeasonNumber = null,
|
||||||
nextUpSeedEpisodeNumber = null,
|
nextUpSeedEpisodeNumber = null,
|
||||||
|
|
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
|
||||||
episodeTitle = nextEpisode.title,
|
episodeTitle = nextEpisode.title,
|
||||||
episodeThumbnail = nextEpisode.thumbnail,
|
episodeThumbnail = nextEpisode.thumbnail,
|
||||||
pauseDescription = nextEpisode.overview,
|
pauseDescription = nextEpisode.overview,
|
||||||
|
released = nextEpisode.released,
|
||||||
isNextUp = true,
|
isNextUp = true,
|
||||||
nextUpSeedSeasonNumber = seasonNumber,
|
nextUpSeedSeasonNumber = seasonNumber,
|
||||||
nextUpSeedEpisodeNumber = episodeNumber,
|
nextUpSeedEpisodeNumber = episodeNumber,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import com.nuvio.app.features.player.PlayerPlaybackSnapshot
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktProgressRepository
|
import com.nuvio.app.features.trakt.TraktProgressRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||||
|
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
|
||||||
import com.nuvio.app.features.watching.application.WatchingActions
|
import com.nuvio.app.features.watching.application.WatchingActions
|
||||||
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
|
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
|
||||||
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
|
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
|
||||||
|
|
@ -37,7 +39,11 @@ object WatchProgressRepository {
|
||||||
init {
|
init {
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
||||||
if (authenticated) {
|
if (shouldUseTraktProgressSource(
|
||||||
|
isAuthenticated = authenticated,
|
||||||
|
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||||
|
)
|
||||||
|
) {
|
||||||
runCatching { TraktProgressRepository.refreshNow() }
|
runCatching { TraktProgressRepository.refreshNow() }
|
||||||
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } }
|
.onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } }
|
||||||
}
|
}
|
||||||
|
|
@ -45,9 +51,23 @@ object WatchProgressRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncScope.launch {
|
||||||
|
TraktSettingsRepository.uiState.collectLatest { settings ->
|
||||||
|
if (shouldUseTraktProgressSource(
|
||||||
|
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||||
|
source = settings.watchProgressSource,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
runCatching { TraktProgressRepository.refreshNow() }
|
||||||
|
.onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } }
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
TraktProgressRepository.uiState.collectLatest {
|
TraktProgressRepository.uiState.collectLatest {
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (shouldUseTraktProgress()) {
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,19 +76,21 @@ object WatchProgressRepository {
|
||||||
|
|
||||||
fun ensureLoaded() {
|
fun ensureLoaded() {
|
||||||
TraktAuthRepository.ensureLoaded()
|
TraktAuthRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
TraktProgressRepository.ensureLoaded()
|
TraktProgressRepository.ensureLoaded()
|
||||||
if (hasLoaded) return
|
if (hasLoaded) return
|
||||||
loadFromDisk(ProfileRepository.activeProfileId)
|
loadFromDisk(ProfileRepository.activeProfileId)
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (shouldUseTraktProgress()) {
|
||||||
TraktProgressRepository.refreshAsync()
|
TraktProgressRepository.refreshAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onProfileChanged(profileId: Int) {
|
fun onProfileChanged(profileId: Int) {
|
||||||
if (profileId == currentProfileId && hasLoaded) return
|
if (profileId == currentProfileId && hasLoaded) return
|
||||||
|
TraktSettingsRepository.onProfileChanged()
|
||||||
loadFromDisk(profileId)
|
loadFromDisk(profileId)
|
||||||
TraktProgressRepository.onProfileChanged()
|
TraktProgressRepository.onProfileChanged()
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
if (shouldUseTraktProgress()) {
|
||||||
TraktProgressRepository.refreshAsync()
|
TraktProgressRepository.refreshAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +101,7 @@ object WatchProgressRepository {
|
||||||
currentProfileId = 1
|
currentProfileId = 1
|
||||||
entriesByVideoId.clear()
|
entriesByVideoId.clear()
|
||||||
TraktProgressRepository.clearLocalState()
|
TraktProgressRepository.clearLocalState()
|
||||||
|
TraktSettingsRepository.clearLocalState()
|
||||||
_uiState.value = WatchProgressUiState()
|
_uiState.value = WatchProgressUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,9 +121,14 @@ object WatchProgressRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun pullFromServer(profileId: Int) {
|
suspend fun pullFromServer(profileId: Int) {
|
||||||
|
TraktAuthRepository.ensureLoaded()
|
||||||
|
TraktSettingsRepository.ensureLoaded()
|
||||||
|
TraktProgressRepository.ensureLoaded()
|
||||||
currentProfileId = profileId
|
currentProfileId = profileId
|
||||||
|
|
||||||
if (TraktAuthRepository.isAuthenticated.value) {
|
val useTraktProgress = shouldUseTraktProgress()
|
||||||
|
|
||||||
|
if (useTraktProgress) {
|
||||||
runCatching { TraktProgressRepository.refreshNow() }
|
runCatching { TraktProgressRepository.refreshNow() }
|
||||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
|
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -368,7 +396,6 @@ object WatchProgressRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pushScrobbleToServer(entry: WatchProgressEntry) {
|
private fun pushScrobbleToServer(entry: WatchProgressEntry) {
|
||||||
if (shouldUseTraktProgress()) return
|
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val profileId = ProfileRepository.activeProfileId
|
val profileId = ProfileRepository.activeProfileId
|
||||||
|
|
@ -394,8 +421,9 @@ object WatchProgressRepository {
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
val entries = currentEntries()
|
val entries = currentEntries()
|
||||||
|
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
|
||||||
_uiState.value = WatchProgressUiState(
|
_uiState.value = WatchProgressUiState(
|
||||||
entries = entries.sortedByDescending { it.lastUpdatedEpochMs },
|
entries = sortedEntries,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -406,7 +434,11 @@ object WatchProgressRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value
|
private fun shouldUseTraktProgress(): Boolean =
|
||||||
|
shouldUseTraktProgressSource(
|
||||||
|
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||||
|
source = TraktSettingsRepository.uiState.value.watchProgressSource,
|
||||||
|
)
|
||||||
|
|
||||||
private fun currentEntries(): List<WatchProgressEntry> {
|
private fun currentEntries(): List<WatchProgressEntry> {
|
||||||
return if (shouldUseTraktProgress()) {
|
return if (shouldUseTraktProgress()) {
|
||||||
|
|
|
||||||
|
|
@ -67,15 +67,50 @@ internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): Watc
|
||||||
internal fun List<WatchProgressEntry>.continueWatchingEntries(
|
internal fun List<WatchProgressEntry>.continueWatchingEntries(
|
||||||
limit: Int = ContinueWatchingLimit,
|
limit: Int = ContinueWatchingLimit,
|
||||||
): List<WatchProgressEntry> {
|
): List<WatchProgressEntry> {
|
||||||
|
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
|
||||||
val domainEntries = continueWatchingProgressEntries(
|
val domainEntries = continueWatchingProgressEntries(
|
||||||
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
|
progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
|
||||||
limit = limit,
|
limit = limit,
|
||||||
)
|
)
|
||||||
val ids = domainEntries.map { record -> record.videoId }.toSet()
|
val ids = domainEntries.map { record -> record.videoId }.toSet()
|
||||||
return filter { entry -> entry.videoId in ids }
|
return inProgressEntries.filter { entry -> entry.videoId in ids }
|
||||||
.sortedByDescending { it.lastUpdatedEpochMs }
|
.sortedByDescending { it.lastUpdatedEpochMs }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Boolean {
|
||||||
|
val entry = normalizedCompletion()
|
||||||
|
if (entry.isEffectivelyCompleted) return false
|
||||||
|
|
||||||
|
val hasStartedPlayback = entry.lastPositionMs > 0L ||
|
||||||
|
entry.normalizedProgressPercent?.let { it > 0f } == true
|
||||||
|
if (!hasStartedPlayback) return false
|
||||||
|
|
||||||
|
return entry.source != WatchProgressSourceTraktHistory &&
|
||||||
|
entry.source != WatchProgressSourceTraktShowProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean {
|
||||||
|
val entry = normalizedCompletion()
|
||||||
|
if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false
|
||||||
|
if (!entry.isEffectivelyCompleted) return false
|
||||||
|
if (entry.source != WatchProgressSourceTraktPlayback) return true
|
||||||
|
|
||||||
|
val explicitPercent = entry.normalizedProgressPercent ?: return false
|
||||||
|
return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.isSeriesTypeForContinueWatching(): Boolean =
|
||||||
|
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
|
||||||
|
|
||||||
|
internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
|
||||||
|
val trimmed = contentId?.trim().orEmpty()
|
||||||
|
if (trimmed.isEmpty()) return true
|
||||||
|
return when (trimmed.lowercase()) {
|
||||||
|
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||||
normalizedCompletion().let { entry ->
|
normalizedCompletion().let { entry ->
|
||||||
WatchingProgressRecord(
|
WatchingProgressRecord(
|
||||||
|
|
|
||||||
|
|
@ -49,4 +49,26 @@ class HomeCatalogParserTest {
|
||||||
result.items.map { it.stableKey() },
|
result.items.map { it.stableKey() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse catalog response keeps raw released date for unreleased filtering`() {
|
||||||
|
val result = HomeCatalogParser.parseCatalogResponse(
|
||||||
|
payload = """
|
||||||
|
{
|
||||||
|
"metas": [
|
||||||
|
{
|
||||||
|
"id": "tt1",
|
||||||
|
"type": "movie",
|
||||||
|
"name": "Future Movie",
|
||||||
|
"releaseInfo": "2027",
|
||||||
|
"released": "2027-05-12T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("2027", result.items.single().releaseInfo)
|
||||||
|
assertEquals("2027-05-12T00:00:00.000Z", result.items.single().rawReleaseDate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.home
|
||||||
|
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||||
|
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
|
@ -60,6 +61,91 @@ class HomeScreenTest {
|
||||||
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
|
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build home continue watching items suppresses next up when series has in progress resume`() {
|
||||||
|
val inProgress = progressEntry(
|
||||||
|
videoId = "show:1:4",
|
||||||
|
title = "Show",
|
||||||
|
episodeNumber = 4,
|
||||||
|
episodeTitle = "Current",
|
||||||
|
lastUpdatedEpochMs = 200L,
|
||||||
|
)
|
||||||
|
val nextUp = continueWatchingItem(
|
||||||
|
videoId = "show:1:5",
|
||||||
|
subtitle = "Up Next • S1E5 • Next",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = buildHomeContinueWatchingItems(
|
||||||
|
visibleEntries = listOf(inProgress),
|
||||||
|
nextUpItemsBySeries = mapOf("show" to (500L to nextUp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId))
|
||||||
|
assertEquals("S1E4 • Current", result.single().subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
||||||
|
val oldEntry = progressEntry(
|
||||||
|
videoId = "old",
|
||||||
|
title = "Old",
|
||||||
|
lastUpdatedEpochMs = 1_000L,
|
||||||
|
seasonNumber = null,
|
||||||
|
episodeNumber = null,
|
||||||
|
)
|
||||||
|
val recentEntry = progressEntry(
|
||||||
|
videoId = "recent",
|
||||||
|
title = "Recent",
|
||||||
|
lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
|
||||||
|
seasonNumber = null,
|
||||||
|
episodeNumber = null,
|
||||||
|
)
|
||||||
|
val entries = listOf(oldEntry, recentEntry)
|
||||||
|
|
||||||
|
val filtered = filterEntriesForTraktContinueWatchingWindow(
|
||||||
|
entries = entries,
|
||||||
|
isTraktProgressActive = true,
|
||||||
|
daysCap = 60,
|
||||||
|
nowEpochMs = 90L * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
val nuvioSource = filterEntriesForTraktContinueWatchingWindow(
|
||||||
|
entries = entries,
|
||||||
|
isTraktProgressActive = false,
|
||||||
|
daysCap = 60,
|
||||||
|
nowEpochMs = 90L * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId))
|
||||||
|
assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Trakt all history window keeps old progress`() {
|
||||||
|
val oldEntry = progressEntry(
|
||||||
|
videoId = "old",
|
||||||
|
title = "Old",
|
||||||
|
lastUpdatedEpochMs = 1_000L,
|
||||||
|
seasonNumber = null,
|
||||||
|
episodeNumber = null,
|
||||||
|
)
|
||||||
|
val recentEntry = progressEntry(
|
||||||
|
videoId = "recent",
|
||||||
|
title = "Recent",
|
||||||
|
lastUpdatedEpochMs = 30L * MILLIS_PER_DAY,
|
||||||
|
seasonNumber = null,
|
||||||
|
episodeNumber = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = filterEntriesForTraktContinueWatchingWindow(
|
||||||
|
entries = listOf(oldEntry, recentEntry),
|
||||||
|
isTraktProgressActive = true,
|
||||||
|
daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL,
|
||||||
|
nowEpochMs = 90L * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId))
|
||||||
|
}
|
||||||
|
|
||||||
private fun progressEntry(
|
private fun progressEntry(
|
||||||
videoId: String,
|
videoId: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -100,4 +186,8 @@ class HomeScreenTest {
|
||||||
durationMs = 0L,
|
durationMs = 0L,
|
||||||
progressFraction = 0f,
|
progressFraction = 0f,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
private companion object {
|
||||||
|
const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.nuvio.app.features.library
|
||||||
|
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
import com.nuvio.app.features.trakt.TraktListTab
|
||||||
|
import com.nuvio.app.features.trakt.TraktListType
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
|
@ -37,4 +39,34 @@ class LibraryRepositoryTest {
|
||||||
assertEquals(PosterShape.Poster, preview.posterShape)
|
assertEquals(PosterShape.Poster, preview.posterShape)
|
||||||
assertEquals("banner", preview.banner)
|
assertEquals("banner", preview.banner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `library tabs include local Nuvio library before Trakt tabs`() {
|
||||||
|
val traktTab = TraktListTab(
|
||||||
|
key = "trakt:watchlist",
|
||||||
|
title = "Watchlist",
|
||||||
|
type = TraktListType.WATCHLIST,
|
||||||
|
)
|
||||||
|
|
||||||
|
val tabs = libraryTabsWithLocal(listOf(traktTab))
|
||||||
|
|
||||||
|
assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key })
|
||||||
|
assertEquals("Nuvio Library", tabs.first().title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `library membership always includes local state before Trakt membership`() {
|
||||||
|
val membership = libraryMembershipWithLocal(
|
||||||
|
inLocal = true,
|
||||||
|
traktMembership = mapOf("trakt:watchlist" to false),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
mapOf(
|
||||||
|
"local" to true,
|
||||||
|
"trakt:watchlist" to false,
|
||||||
|
),
|
||||||
|
membership,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
assertEquals(listOf("movie-progress"), result.map { it.videoId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `continue watching keeps active resume even when a newer episode is completed`() {
|
||||||
|
val inProgress = entry(
|
||||||
|
videoId = "show:1:4",
|
||||||
|
parentMetaId = "show",
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 4,
|
||||||
|
lastUpdatedEpochMs = 10L,
|
||||||
|
)
|
||||||
|
val completed = entry(
|
||||||
|
videoId = "show:1:5",
|
||||||
|
parentMetaId = "show",
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 5,
|
||||||
|
lastUpdatedEpochMs = 20L,
|
||||||
|
isCompleted = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = listOf(inProgress, completed).continueWatchingEntries()
|
||||||
|
|
||||||
|
assertEquals(listOf("show:1:4"), result.map { it.videoId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Trakt playback next up seeds require TV percent threshold`() {
|
||||||
|
val belowSeedThreshold = entry(
|
||||||
|
videoId = "show:1:4",
|
||||||
|
parentMetaId = "show",
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 4,
|
||||||
|
progressPercent = 94f,
|
||||||
|
source = WatchProgressSourceTraktPlayback,
|
||||||
|
)
|
||||||
|
val seed = belowSeedThreshold.copy(progressPercent = 95f)
|
||||||
|
|
||||||
|
assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching())
|
||||||
|
assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Trakt history is not treated as active resume`() {
|
||||||
|
val history = entry(
|
||||||
|
videoId = "show:1:4",
|
||||||
|
parentMetaId = "show",
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 4,
|
||||||
|
lastPositionMs = 1L,
|
||||||
|
durationMs = 0L,
|
||||||
|
progressPercent = 50f,
|
||||||
|
source = WatchProgressSourceTraktHistory,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(history.shouldTreatAsInProgressForContinueWatching())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `codec normalizes completed entries inferred from percent`() {
|
fun `codec normalizes completed entries inferred from percent`() {
|
||||||
val payload = WatchProgressCodec.encodeEntries(
|
val payload = WatchProgressCodec.encodeEntries(
|
||||||
|
|
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
|
||||||
durationMs: Long = 1_000_000L,
|
durationMs: Long = 1_000_000L,
|
||||||
isCompleted: Boolean = false,
|
isCompleted: Boolean = false,
|
||||||
progressPercent: Float? = null,
|
progressPercent: Float? = null,
|
||||||
|
source: String = WatchProgressSourceLocal,
|
||||||
): WatchProgressEntry =
|
): WatchProgressEntry =
|
||||||
WatchProgressEntry(
|
WatchProgressEntry(
|
||||||
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
|
||||||
|
|
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
|
||||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||||
isCompleted = isCompleted,
|
isCompleted = isCompleted,
|
||||||
progressPercent = progressPercent,
|
progressPercent = progressPercent,
|
||||||
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"mdblist_use_audience",
|
"mdblist_use_audience",
|
||||||
"trakt_auth_payload",
|
"trakt_auth_payload",
|
||||||
"trakt_library_payload",
|
"trakt_library_payload",
|
||||||
|
"trakt_settings_payload",
|
||||||
|
"collections_payload",
|
||||||
)
|
)
|
||||||
|
|
||||||
actual fun wipe() {
|
actual fun wipe() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
actual object ThemeSettingsStorage {
|
||||||
private const val selectedThemeKey = "selected_theme"
|
private const val selectedThemeKey = "selected_theme"
|
||||||
private const val amoledEnabledKey = "amoled_enabled"
|
private const val amoledEnabledKey = "amoled_enabled"
|
||||||
|
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||||
private const val selectedAppLanguageKey = "selected_app_language"
|
private const val selectedAppLanguageKey = "selected_app_language"
|
||||||
private val profileScopedSyncKeys = listOf(selectedThemeKey, amoledEnabledKey)
|
private val profileScopedSyncKeys = listOf(
|
||||||
|
selectedThemeKey,
|
||||||
|
amoledEnabledKey,
|
||||||
|
liquidGlassNativeTabBarEnabledKey,
|
||||||
|
)
|
||||||
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
private val globalSyncKeys = listOf(selectedAppLanguageKey)
|
||||||
|
|
||||||
actual fun loadSelectedTheme(): String? =
|
actual fun loadSelectedTheme(): String? =
|
||||||
|
|
@ -38,6 +43,23 @@ actual object ThemeSettingsStorage {
|
||||||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey))
|
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? {
|
||||||
|
val defaults = NSUserDefaults.standardUserDefaults
|
||||||
|
val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)
|
||||||
|
return if (defaults.objectForKey(key) != null) {
|
||||||
|
defaults.boolForKey(key)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setBool(
|
||||||
|
enabled,
|
||||||
|
forKey = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
actual fun loadSelectedAppLanguage(): String? {
|
actual fun loadSelectedAppLanguage(): String? {
|
||||||
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
|
val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey)
|
||||||
if (value != null) return value
|
if (value != null) return value
|
||||||
|
|
@ -65,6 +87,7 @@ actual object ThemeSettingsStorage {
|
||||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||||
|
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||||
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +101,7 @@ actual object ThemeSettingsStorage {
|
||||||
|
|
||||||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||||
|
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||||
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
|
||||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
CURRENT_PROJECT_VERSION=54
|
||||||
MARKETING_VERSION=0.1.13
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,316 @@ import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
final class RootComposeViewController: UIViewController {
|
private enum NuvioNativeTabIcon {
|
||||||
|
static let home = vectorIcon(
|
||||||
|
viewport: CGSize(width: 24, height: 24),
|
||||||
|
paths: [
|
||||||
|
"M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
static let search = drawnIcon { context, rect in
|
||||||
|
drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) {
|
||||||
|
context.setStrokeColor(UIColor.black.cgColor)
|
||||||
|
context.setLineWidth(2)
|
||||||
|
context.setLineCap(.round)
|
||||||
|
context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12))
|
||||||
|
context.move(to: CGPoint(x: 13.6, y: 13.6))
|
||||||
|
context.addLine(to: CGPoint(x: 17, y: 17))
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let library = vectorIcon(
|
||||||
|
viewport: CGSize(width: 24, height: 24),
|
||||||
|
paths: [
|
||||||
|
"M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z",
|
||||||
|
"M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z",
|
||||||
|
"M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
static let profileFallback = vectorIcon(
|
||||||
|
viewport: CGSize(width: 24, height: 24),
|
||||||
|
paths: [
|
||||||
|
"M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
static func profileAvatar(
|
||||||
|
name: String?,
|
||||||
|
avatarColor: UIColor?,
|
||||||
|
backgroundColor: UIColor?,
|
||||||
|
avatarImage: UIImage?,
|
||||||
|
selected: Bool,
|
||||||
|
accent: UIColor
|
||||||
|
) -> UIImage {
|
||||||
|
guard name != nil || avatarColor != nil || avatarImage != nil else {
|
||||||
|
return profileFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = CGSize(width: 28, height: 28)
|
||||||
|
let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
|
||||||
|
let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15)
|
||||||
|
let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5)
|
||||||
|
let initial = name?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.prefix(1)
|
||||||
|
.uppercased() ?? ""
|
||||||
|
|
||||||
|
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||||
|
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1)
|
||||||
|
fillColor.setFill()
|
||||||
|
UIBezierPath(ovalIn: rect).fill()
|
||||||
|
|
||||||
|
if let avatarImage {
|
||||||
|
UIBezierPath(ovalIn: rect).addClip()
|
||||||
|
drawAspectFill(image: avatarImage, in: rect)
|
||||||
|
} else if !initial.isEmpty {
|
||||||
|
let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: baseColor,
|
||||||
|
]
|
||||||
|
let textSize = initial.size(withAttributes: attributes)
|
||||||
|
initial.draw(
|
||||||
|
at: CGPoint(
|
||||||
|
x: rect.midX - textSize.width / 2,
|
||||||
|
y: rect.midY - textSize.height / 2
|
||||||
|
),
|
||||||
|
withAttributes: attributes
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
profileFallback
|
||||||
|
.withTintColor(baseColor, renderingMode: .alwaysOriginal)
|
||||||
|
.draw(in: rect.insetBy(dx: 5.5, dy: 5.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
borderColor.setStroke()
|
||||||
|
let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75))
|
||||||
|
borderPath.lineWidth = 1.5
|
||||||
|
borderPath.stroke()
|
||||||
|
}.withRenderingMode(.alwaysOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawInViewport(
|
||||||
|
context: CGContext,
|
||||||
|
rect: CGRect,
|
||||||
|
viewport: CGSize,
|
||||||
|
draw: () -> Void
|
||||||
|
) {
|
||||||
|
let scale = min(rect.width / viewport.width, rect.height / viewport.height)
|
||||||
|
let x = rect.midX - viewport.width * scale / 2
|
||||||
|
let y = rect.midY - viewport.height * scale / 2
|
||||||
|
context.saveGState()
|
||||||
|
context.translateBy(x: x, y: y)
|
||||||
|
context.scaleBy(x: scale, y: scale)
|
||||||
|
draw()
|
||||||
|
context.restoreGState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage {
|
||||||
|
drawnIcon(size: size) { context, rect in
|
||||||
|
drawInViewport(context: context, rect: rect, viewport: viewport) {
|
||||||
|
context.setFillColor(UIColor.black.cgColor)
|
||||||
|
paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in
|
||||||
|
context.addPath(path)
|
||||||
|
context.fillPath(using: .evenOdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawnIcon(
|
||||||
|
size: CGSize = CGSize(width: 25, height: 25),
|
||||||
|
draw: @escaping (CGContext, CGRect) -> Void
|
||||||
|
) -> UIImage {
|
||||||
|
UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||||
|
draw(rendererContext.cgContext, CGRect(origin: .zero, size: size))
|
||||||
|
}.withRenderingMode(.alwaysTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawAspectFill(image: UIImage, in rect: CGRect) {
|
||||||
|
guard image.size.width > 0, image.size.height > 0 else { return }
|
||||||
|
let scale = max(rect.width / image.size.width, rect.height / image.size.height)
|
||||||
|
let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
let drawRect = CGRect(
|
||||||
|
x: rect.midX - drawSize.width / 2,
|
||||||
|
y: rect.midY - drawSize.height / 2,
|
||||||
|
width: drawSize.width,
|
||||||
|
height: drawSize.height
|
||||||
|
)
|
||||||
|
image.draw(in: drawRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SVGPath {
|
||||||
|
private enum Token {
|
||||||
|
case command(Character)
|
||||||
|
case number(CGFloat)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: String
|
||||||
|
|
||||||
|
var cgPath: CGPath? {
|
||||||
|
let tokens = Self.tokens(from: data)
|
||||||
|
var index = 0
|
||||||
|
var command: Character?
|
||||||
|
var current = CGPoint.zero
|
||||||
|
var subpathStart = CGPoint.zero
|
||||||
|
let path = CGMutablePath()
|
||||||
|
|
||||||
|
func hasNumber() -> Bool {
|
||||||
|
guard index < tokens.count else { return false }
|
||||||
|
if case .number = tokens[index] { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNumber() -> CGFloat? {
|
||||||
|
guard index < tokens.count else { return nil }
|
||||||
|
guard case let .number(value) = tokens[index] else { return nil }
|
||||||
|
index += 1
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPoint(relative: Bool) -> CGPoint? {
|
||||||
|
guard let x = readNumber(), let y = readNumber() else { return nil }
|
||||||
|
let point = CGPoint(x: x, y: y)
|
||||||
|
return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point
|
||||||
|
}
|
||||||
|
|
||||||
|
while index < tokens.count {
|
||||||
|
if case let .command(value) = tokens[index] {
|
||||||
|
command = value
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeCommand = command else { return nil }
|
||||||
|
let relative = activeCommand.isLowercase
|
||||||
|
|
||||||
|
switch activeCommand.uppercased() {
|
||||||
|
case "M":
|
||||||
|
guard let point = readPoint(relative: relative) else { return nil }
|
||||||
|
path.move(to: point)
|
||||||
|
current = point
|
||||||
|
subpathStart = point
|
||||||
|
command = relative ? "l" : "L"
|
||||||
|
case "L":
|
||||||
|
while hasNumber() {
|
||||||
|
guard let point = readPoint(relative: relative) else { return nil }
|
||||||
|
path.addLine(to: point)
|
||||||
|
current = point
|
||||||
|
}
|
||||||
|
case "H":
|
||||||
|
while hasNumber() {
|
||||||
|
guard let x = readNumber() else { return nil }
|
||||||
|
let point = CGPoint(x: relative ? current.x + x : x, y: current.y)
|
||||||
|
path.addLine(to: point)
|
||||||
|
current = point
|
||||||
|
}
|
||||||
|
case "V":
|
||||||
|
while hasNumber() {
|
||||||
|
guard let y = readNumber() else { return nil }
|
||||||
|
let point = CGPoint(x: current.x, y: relative ? current.y + y : y)
|
||||||
|
path.addLine(to: point)
|
||||||
|
current = point
|
||||||
|
}
|
||||||
|
case "C":
|
||||||
|
while hasNumber() {
|
||||||
|
guard
|
||||||
|
let c1 = readPoint(relative: relative),
|
||||||
|
let c2 = readPoint(relative: relative),
|
||||||
|
let end = readPoint(relative: relative)
|
||||||
|
else { return nil }
|
||||||
|
path.addCurve(to: end, control1: c1, control2: c2)
|
||||||
|
current = end
|
||||||
|
}
|
||||||
|
case "Z":
|
||||||
|
path.closeSubpath()
|
||||||
|
current = subpathStart
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tokens(from data: String) -> [Token] {
|
||||||
|
let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?"
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||||
|
let range = NSRange(data.startIndex..<data.endIndex, in: data)
|
||||||
|
return regex.matches(in: data, range: range).compactMap { match in
|
||||||
|
guard let tokenRange = Range(match.range, in: data) else { return nil }
|
||||||
|
let token = String(data[tokenRange])
|
||||||
|
if token.count == 1, let character = token.first, character.isLetter {
|
||||||
|
return .command(character)
|
||||||
|
}
|
||||||
|
guard let value = Double(token) else { return nil }
|
||||||
|
return .number(CGFloat(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RootComposeViewController: UIViewController, UITabBarDelegate {
|
||||||
|
private enum NativeTab: String, CaseIterable {
|
||||||
|
case home = "Home"
|
||||||
|
case search = "Search"
|
||||||
|
case library = "Library"
|
||||||
|
case settings = "Settings"
|
||||||
|
|
||||||
|
var tag: Int {
|
||||||
|
switch self {
|
||||||
|
case .home: return 0
|
||||||
|
case .search: return 1
|
||||||
|
case .library: return 2
|
||||||
|
case .settings: return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .home: return "Home"
|
||||||
|
case .search: return "Search"
|
||||||
|
case .library: return "Library"
|
||||||
|
case .settings: return "Profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconImage: UIImage {
|
||||||
|
switch self {
|
||||||
|
case .home: return NuvioNativeTabIcon.home
|
||||||
|
case .search: return NuvioNativeTabIcon.search
|
||||||
|
case .library: return NuvioNativeTabIcon.library
|
||||||
|
case .settings: return NuvioNativeTabIcon.profileFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(tag: Int) {
|
||||||
|
guard let tab = Self.allCases.first(where: { $0.tag == tag }) else { return nil }
|
||||||
|
self = tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
|
||||||
|
private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
|
||||||
|
private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
|
||||||
|
private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
|
||||||
|
private static let nativeProfileNameKey = "NuvioNativeProfileName"
|
||||||
|
private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
|
||||||
|
private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL"
|
||||||
|
private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
|
||||||
|
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
|
||||||
|
|
||||||
private let contentController: UIViewController
|
private let contentController: UIViewController
|
||||||
|
private let tabBar = UITabBar()
|
||||||
|
private var contentBottomToViewBottom: NSLayoutConstraint?
|
||||||
|
private var tabBarHeightConstraint: NSLayoutConstraint?
|
||||||
|
private var userDefaultsObserver: NSObjectProtocol?
|
||||||
|
private var tabChromeObserver: NSObjectProtocol?
|
||||||
|
private var profileAvatarImageURL: String?
|
||||||
|
private var profileAvatarImageTask: URLSessionDataTask?
|
||||||
|
private var profileAvatarImage: UIImage?
|
||||||
|
|
||||||
init(contentController: UIViewController) {
|
init(contentController: UIViewController) {
|
||||||
self.contentController = contentController
|
self.contentController = contentController
|
||||||
|
|
@ -20,17 +328,45 @@ final class RootComposeViewController: UIViewController {
|
||||||
|
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
contentController.view.backgroundColor = .black
|
contentController.view.backgroundColor = .black
|
||||||
|
UserDefaults.standard.set(false, forKey: Self.nativeTabBarVisibleKey)
|
||||||
|
|
||||||
addChild(contentController)
|
addChild(contentController)
|
||||||
view.addSubview(contentController.view)
|
view.addSubview(contentController.view)
|
||||||
contentController.view.translatesAutoresizingMaskIntoConstraints = false
|
contentController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
let bottomToViewBottom = contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
self.contentBottomToViewBottom = bottomToViewBottom
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
bottomToViewBottom,
|
||||||
])
|
])
|
||||||
contentController.didMove(toParent: self)
|
contentController.didMove(toParent: self)
|
||||||
|
|
||||||
|
configureNativeTabBar()
|
||||||
|
installNativeTabObservers()
|
||||||
|
syncNativeTabChrome(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let userDefaultsObserver {
|
||||||
|
NotificationCenter.default.removeObserver(userDefaultsObserver)
|
||||||
|
}
|
||||||
|
if let tabChromeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(tabChromeObserver)
|
||||||
|
}
|
||||||
|
profileAvatarImageTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
|
super.viewSafeAreaInsetsDidChange()
|
||||||
|
updateTabBarHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
||||||
|
guard let tab = NativeTab(tag: item.tag) else { return }
|
||||||
|
UserDefaults.standard.set(tab.rawValue, forKey: Self.nativeSelectedTabKey)
|
||||||
|
NativeTabBridgeKt.nativeTabSelect(tabName: tab.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
override var childForHomeIndicatorAutoHidden: UIViewController? {
|
override var childForHomeIndicatorAutoHidden: UIViewController? {
|
||||||
|
|
@ -88,6 +424,210 @@ final class RootComposeViewController: UIViewController {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nativeTabsSupported: Bool {
|
||||||
|
UIDevice.current.userInterfaceIdiom == .phone &&
|
||||||
|
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowNativeTabBar: Bool {
|
||||||
|
nativeTabsSupported &&
|
||||||
|
UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) &&
|
||||||
|
UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureNativeTabBar() {
|
||||||
|
tabBar.delegate = self
|
||||||
|
tabBar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
tabBar.items = NativeTab.allCases.map { tab in
|
||||||
|
let item = UITabBarItem(
|
||||||
|
title: tab.title,
|
||||||
|
image: tab.iconImage,
|
||||||
|
selectedImage: tab.iconImage
|
||||||
|
)
|
||||||
|
item.tag = tab.tag
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
tabBar.selectedItem = tabBar.items?.first
|
||||||
|
applyNativeTabBarAppearance()
|
||||||
|
tabBar.alpha = 0
|
||||||
|
tabBar.isHidden = true
|
||||||
|
|
||||||
|
view.addSubview(tabBar)
|
||||||
|
let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight)
|
||||||
|
tabBarHeightConstraint = heightConstraint
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
heightConstraint,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installNativeTabObservers() {
|
||||||
|
userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: UserDefaults.didChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.syncNativeTabChrome(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
tabChromeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: Self.nativeTabChromeDidChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.syncNativeTabChrome(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tabBarHeight: CGFloat {
|
||||||
|
49 + view.safeAreaInsets.bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTabBarHeight() {
|
||||||
|
tabBarHeightConstraint?.constant = tabBarHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncNativeTabChrome(animated: Bool) {
|
||||||
|
updateTabBarHeight()
|
||||||
|
applyNativeTabBarAppearance()
|
||||||
|
syncSelectedNativeTab()
|
||||||
|
|
||||||
|
let visible = shouldShowNativeTabBar
|
||||||
|
contentBottomToViewBottom?.isActive = true
|
||||||
|
if visible {
|
||||||
|
tabBar.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let changes = {
|
||||||
|
self.tabBar.alpha = visible ? 1 : 0
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
let completion: (Bool) -> Void = { _ in
|
||||||
|
self.tabBar.isHidden = !visible
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated && view.window != nil {
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.22,
|
||||||
|
delay: 0,
|
||||||
|
options: [.beginFromCurrentState, .curveEaseInOut],
|
||||||
|
animations: changes,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
changes()
|
||||||
|
completion(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncSelectedNativeTab() {
|
||||||
|
let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue
|
||||||
|
let selectedTab = NativeTab(rawValue: rawValue) ?? .home
|
||||||
|
tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyNativeTabBarAppearance() {
|
||||||
|
let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ??
|
||||||
|
UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1)
|
||||||
|
let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1)
|
||||||
|
|
||||||
|
refreshProfileAvatarImageIfNeeded()
|
||||||
|
updateNativeTabImages(accent: accent)
|
||||||
|
|
||||||
|
tabBar.tintColor = accent
|
||||||
|
tabBar.unselectedItemTintColor = unselected
|
||||||
|
|
||||||
|
let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance
|
||||||
|
appearance.stackedLayoutAppearance.normal.iconColor = unselected
|
||||||
|
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||||
|
appearance.stackedLayoutAppearance.selected.iconColor = accent
|
||||||
|
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||||
|
appearance.inlineLayoutAppearance.normal.iconColor = unselected
|
||||||
|
appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||||
|
appearance.inlineLayoutAppearance.selected.iconColor = accent
|
||||||
|
appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||||
|
appearance.compactInlineLayoutAppearance.normal.iconColor = unselected
|
||||||
|
appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
|
||||||
|
appearance.compactInlineLayoutAppearance.selected.iconColor = accent
|
||||||
|
appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
|
||||||
|
tabBar.standardAppearance = appearance
|
||||||
|
tabBar.scrollEdgeAppearance = appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNativeTabImages(accent: UIColor) {
|
||||||
|
tabBar.items?.forEach { item in
|
||||||
|
guard let tab = NativeTab(tag: item.tag) else { return }
|
||||||
|
item.image = nativeTabImage(for: tab, selected: false, accent: accent)
|
||||||
|
item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage {
|
||||||
|
guard tab == .settings else {
|
||||||
|
return tab.iconImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
return NuvioNativeTabIcon.profileAvatar(
|
||||||
|
name: defaults.string(forKey: Self.nativeProfileNameKey),
|
||||||
|
avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)),
|
||||||
|
backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)),
|
||||||
|
avatarImage: profileAvatarImage,
|
||||||
|
selected: selected,
|
||||||
|
accent: accent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshProfileAvatarImageIfNeeded() {
|
||||||
|
let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey)
|
||||||
|
guard urlString != profileAvatarImageURL else { return }
|
||||||
|
|
||||||
|
profileAvatarImageTask?.cancel()
|
||||||
|
profileAvatarImageTask = nil
|
||||||
|
profileAvatarImageURL = urlString
|
||||||
|
profileAvatarImage = nil
|
||||||
|
|
||||||
|
guard let urlString, let url = URL(string: urlString) else { return }
|
||||||
|
|
||||||
|
profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||||
|
guard
|
||||||
|
let self,
|
||||||
|
let data,
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard self.profileAvatarImageURL == urlString else { return }
|
||||||
|
self.profileAvatarImage = image
|
||||||
|
self.applyNativeTabBarAppearance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profileAvatarImageTask?.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
convenience init?(hexString: String?) {
|
||||||
|
guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if value.hasPrefix("#") {
|
||||||
|
value.removeFirst()
|
||||||
|
}
|
||||||
|
guard value.count == 6, let rgb = UInt64(value, radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((rgb >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((rgb >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(rgb & 0xFF) / 255,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ComposeView: UIViewControllerRepresentable {
|
struct ComposeView: UIViewControllerRepresentable {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue