mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'cmp-rewrite' into patch-25
This commit is contained in:
commit
4dc054e51e
73 changed files with 4568 additions and 313 deletions
|
|
@ -260,6 +260,7 @@ kotlin {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.ktor3)
|
implementation(libs.coil.network.ktor3)
|
||||||
|
implementation(libs.coil.svg)
|
||||||
implementation("dev.chrisbanes.haze:haze:1.7.2")
|
implementation("dev.chrisbanes.haze:haze:1.7.2")
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage
|
||||||
import com.nuvio.app.core.deeplink.handleAppUrl
|
import com.nuvio.app.core.deeplink.handleAppUrl
|
||||||
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||||
import com.nuvio.app.features.addons.AddonStorage
|
import com.nuvio.app.features.addons.AddonStorage
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
import com.nuvio.app.features.collection.CollectionStorage
|
import com.nuvio.app.features.collection.CollectionStorage
|
||||||
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
||||||
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
||||||
|
|
@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
WatchProgressStorage.initialize(applicationContext)
|
WatchProgressStorage.initialize(applicationContext)
|
||||||
StreamLinkCacheStorage.initialize(applicationContext)
|
StreamLinkCacheStorage.initialize(applicationContext)
|
||||||
PluginStorage.initialize(applicationContext)
|
PluginStorage.initialize(applicationContext)
|
||||||
|
CollectionMobileSettingsStorage.initialize(applicationContext)
|
||||||
CollectionStorage.initialize(applicationContext)
|
CollectionStorage.initialize(applicationContext)
|
||||||
DownloadsStorage.initialize(applicationContext)
|
DownloadsStorage.initialize(applicationContext)
|
||||||
DownloadsPlatformDownloader.initialize(applicationContext)
|
DownloadsPlatformDownloader.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"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_collection_mobile_settings",
|
||||||
"nuvio_collections",
|
"nuvio_collections",
|
||||||
"nuvio_plugins",
|
"nuvio_plugins",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.nuvio.app.features.collection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
|
||||||
|
actual object CollectionMobileSettingsStorage {
|
||||||
|
private const val preferencesName = "nuvio_collection_mobile_settings"
|
||||||
|
private const val payloadKey = "collection_mobile_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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.nuvio.app.features.profiles
|
||||||
|
|
||||||
|
internal actual object ProfileHoverHapticFeedback {
|
||||||
|
actual fun prepare() = Unit
|
||||||
|
actual fun perform() = Unit
|
||||||
|
actual fun release() = Unit
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import com.nuvio.app.R
|
import com.nuvio.app.R
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.introdb_favicon
|
||||||
import nuvio.composeapp.generated.resources.rating_tmdb
|
import nuvio.composeapp.generated.resources.rating_tmdb
|
||||||
import org.jetbrains.compose.resources.painterResource as composePainterResource
|
import org.jetbrains.compose.resources.painterResource as composePainterResource
|
||||||
|
|
||||||
|
|
@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
|
||||||
IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb)
|
IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb)
|
||||||
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
|
IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon)
|
||||||
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
|
IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo)
|
||||||
|
IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import java.time.Instant
|
||||||
internal actual object TraktPlatformClock {
|
internal actual object TraktPlatformClock {
|
||||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching {
|
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
||||||
Instant.parse(value).toEpochMilli()
|
runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
|
||||||
}.getOrNull()
|
?: parseTraktIsoDateTimeToEpochMs(value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,5 @@
|
||||||
<locale android:name="el"/>
|
<locale android:name="el"/>
|
||||||
<locale android:name="pl"/>
|
<locale android:name="pl"/>
|
||||||
<locale android:name="de"/>
|
<locale android:name="de"/>
|
||||||
|
<locale android:name="cs"/>
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
1245
composeApp/src/commonMain/composeResources/values-cs/strings.xml
Normal file
1245
composeApp/src/commonMain/composeResources/values-cs/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
||||||
<resources>
|
<resources>
|
||||||
|
<string name="about_licenses_attributions_subtitle">Data sources, acknowledgements, and platform licenses</string>
|
||||||
<string name="about_supporters_contributors_subtitle">Open recognition and project credits</string>
|
<string name="about_supporters_contributors_subtitle">Open recognition and project credits</string>
|
||||||
<string name="action_back">Back</string>
|
<string name="action_back">Back</string>
|
||||||
<string name="action_cancel">Cancel</string>
|
<string name="action_cancel">Cancel</string>
|
||||||
|
|
@ -366,6 +367,7 @@
|
||||||
<string name="compose_settings_page_continue_watching">Continue Watching</string>
|
<string name="compose_settings_page_continue_watching">Continue Watching</string>
|
||||||
<string name="compose_settings_page_homescreen">Home Layout</string>
|
<string name="compose_settings_page_homescreen">Home Layout</string>
|
||||||
<string name="compose_settings_page_integrations">Integrations</string>
|
<string name="compose_settings_page_integrations">Integrations</string>
|
||||||
|
<string name="compose_settings_page_licenses_attributions">Licenses & Attribution</string>
|
||||||
<string name="compose_settings_page_mdblist_ratings">MDBList Ratings</string>
|
<string name="compose_settings_page_mdblist_ratings">MDBList Ratings</string>
|
||||||
<string name="compose_settings_page_meta_screen">Detail Page</string>
|
<string name="compose_settings_page_meta_screen">Detail Page</string>
|
||||||
<string name="compose_settings_page_notifications">Notifications</string>
|
<string name="compose_settings_page_notifications">Notifications</string>
|
||||||
|
|
@ -391,6 +393,31 @@
|
||||||
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
|
<string name="compose_settings_root_switch_profile_description">Change to a different profile.</string>
|
||||||
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
|
<string name="compose_settings_root_switch_profile_title">Switch Profile</string>
|
||||||
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
|
<string name="compose_settings_root_trakt_description">Open Trakt connection screen</string>
|
||||||
|
<string name="settings_search_empty">No settings found.</string>
|
||||||
|
<string name="settings_search_placeholder">Search settings...</string>
|
||||||
|
<string name="settings_search_results_section">RESULTS</string>
|
||||||
|
<string name="settings_licenses_attributions_section_app">APP LICENSE</string>
|
||||||
|
<string name="settings_licenses_attributions_section_data">DATA & SERVICES</string>
|
||||||
|
<string name="settings_licenses_attributions_section_playback">PLAYBACK LICENSE</string>
|
||||||
|
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobile</string>
|
||||||
|
<string name="settings_licenses_attributions_nuvio_body">Source code and license terms are available in the project repository.</string>
|
||||||
|
<string name="settings_licenses_attributions_nuvio_license">Licensed under the GNU General Public License v3.0.</string>
|
||||||
|
<string name="settings_licenses_attributions_tmdb_title">The Movie Database (TMDB)</string>
|
||||||
|
<string name="settings_licenses_attributions_tmdb_body">Nuvio uses the TMDB API for movie and TV metadata, artwork, trailers, cast, production details, collections, and recommendations. This product uses the TMDB API but is not endorsed or certified by TMDB.</string>
|
||||||
|
<string name="settings_licenses_attributions_imdb_title">IMDb Non-Commercial Datasets</string>
|
||||||
|
<string name="settings_licenses_attributions_imdb_body">Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms.</string>
|
||||||
|
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
|
||||||
|
<string name="settings_licenses_attributions_trakt_body">Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt.</string>
|
||||||
|
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
|
||||||
|
<string name="settings_licenses_attributions_mdblist_body">Nuvio uses MDBList for ratings and external score provider data. Nuvio is not affiliated with or endorsed by MDBList.</string>
|
||||||
|
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
|
||||||
|
<string name="settings_licenses_attributions_introdb_body">Nuvio uses the IntroDB API for community-provided intro, recap, credits, and preview timestamps used by skip controls. Nuvio is not affiliated with or endorsed by IntroDB.</string>
|
||||||
|
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
|
||||||
|
<string name="settings_licenses_attributions_mpvkit_body">Used for playback on iOS builds.</string>
|
||||||
|
<string name="settings_licenses_attributions_mpvkit_license">MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0.</string>
|
||||||
|
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
|
||||||
|
<string name="settings_licenses_attributions_exoplayer_body">Used for playback on Android builds.</string>
|
||||||
|
<string name="settings_licenses_attributions_exoplayer_license">Licensed under the Apache License, Version 2.0.</string>
|
||||||
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
|
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
|
||||||
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
|
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
|
||||||
<string name="action_donate">Donate</string>
|
<string name="action_donate">Donate</string>
|
||||||
|
|
@ -479,6 +506,8 @@
|
||||||
<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">Hide Unreleased Content</string>
|
||||||
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
||||||
|
<string name="settings_homescreen_hide_catalog_underline">Hide Catalog Underline</string>
|
||||||
|
<string name="settings_homescreen_hide_catalog_underline_description">Remove the accent line under catalog and collection titles throughout the app.</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>
|
||||||
|
|
@ -514,6 +543,12 @@
|
||||||
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</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_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_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
|
||||||
|
<string name="settings_continue_watching_section_sort_order">SORT ORDER</string>
|
||||||
|
<string name="settings_continue_watching_sort_mode_title">Sort Order</string>
|
||||||
|
<string name="settings_continue_watching_sort_mode_default">Default</string>
|
||||||
|
<string name="settings_continue_watching_sort_mode_default_desc">Sort all items by recency</string>
|
||||||
|
<string name="settings_continue_watching_sort_mode_streaming">Streaming Style</string>
|
||||||
|
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</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>
|
||||||
|
|
@ -1111,6 +1146,7 @@
|
||||||
<string name="downloads_live_failed">Download failed</string>
|
<string name="downloads_live_failed">Download failed</string>
|
||||||
<string name="downloads_live_paused">Paused %1$s</string>
|
<string name="downloads_live_paused">Paused %1$s</string>
|
||||||
<string name="library_remove_confirm">Remove</string>
|
<string name="library_remove_confirm">Remove</string>
|
||||||
|
<string name="library_remove_from_list_message">Remove %1$s from %2$s?</string>
|
||||||
<string name="library_remove_message">Remove %1$s from your library?</string>
|
<string name="library_remove_message">Remove %1$s from your library?</string>
|
||||||
<string name="library_remove_title">Remove from Library?</string>
|
<string name="library_remove_title">Remove from Library?</string>
|
||||||
<string name="media_movie">Movie</string>
|
<string name="media_movie">Movie</string>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import coil3.ImageLoader
|
||||||
import coil3.compose.setSingletonImageLoaderFactory
|
import coil3.compose.setSingletonImageLoaderFactory
|
||||||
import coil3.request.CachePolicy
|
import coil3.request.CachePolicy
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
|
import coil3.svg.SvgDecoder
|
||||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.core.auth.AuthRepository
|
import com.nuvio.app.core.auth.AuthRepository
|
||||||
import com.nuvio.app.core.auth.AuthState
|
import com.nuvio.app.core.auth.AuthState
|
||||||
|
|
@ -146,6 +147,7 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen
|
||||||
import com.nuvio.app.features.settings.PluginsSettingsScreen
|
import com.nuvio.app.features.settings.PluginsSettingsScreen
|
||||||
import com.nuvio.app.features.settings.AccountSettingsScreen
|
import com.nuvio.app.features.settings.AccountSettingsScreen
|
||||||
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
|
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
|
||||||
|
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
|
||||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionManagementScreen
|
import com.nuvio.app.features.collection.CollectionManagementScreen
|
||||||
import com.nuvio.app.features.collection.CollectionEditorScreen
|
import com.nuvio.app.features.collection.CollectionEditorScreen
|
||||||
|
|
@ -238,6 +240,9 @@ object AccountSettingsRoute
|
||||||
@Serializable
|
@Serializable
|
||||||
object SupportersContributorsSettingsRoute
|
object SupportersContributorsSettingsRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object LicensesAttributionsSettingsRoute
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object CollectionsRoute
|
object CollectionsRoute
|
||||||
|
|
||||||
|
|
@ -301,6 +306,9 @@ fun App() {
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.diskCachePolicy(CachePolicy.ENABLED)
|
.diskCachePolicy(CachePolicy.ENABLED)
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
|
.components {
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
}
|
||||||
.configurePlatformImageLoader()
|
.configurePlatformImageLoader()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
@ -513,6 +521,7 @@ 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 currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
|
||||||
val liquidGlassNativeTabBarEnabled by remember {
|
val liquidGlassNativeTabBarEnabled by remember {
|
||||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||||
|
|
@ -1005,6 +1014,7 @@ private fun MainAppContent(
|
||||||
val isTabletLayout = maxWidth >= 768.dp
|
val isTabletLayout = maxWidth >= 768.dp
|
||||||
val useNativeBottomTabs =
|
val useNativeBottomTabs =
|
||||||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||||
|
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
|
||||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||||
profileSwitchLoading = true
|
profileSwitchLoading = true
|
||||||
selectedTab = AppScreenTab.Home
|
selectedTab = AppScreenTab.Home
|
||||||
|
|
@ -1063,6 +1073,7 @@ private fun MainAppContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
animateHomeCollectionGifs = tabsRouteActive,
|
||||||
onCatalogClick = onCatalogClick,
|
onCatalogClick = onCatalogClick,
|
||||||
onPosterClick = { meta ->
|
onPosterClick = { meta ->
|
||||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||||
|
|
@ -1092,6 +1103,9 @@ private fun MainAppContent(
|
||||||
onSupportersContributorsSettingsClick = {
|
onSupportersContributorsSettingsClick = {
|
||||||
navController.navigate(SupportersContributorsSettingsRoute)
|
navController.navigate(SupportersContributorsSettingsRoute)
|
||||||
},
|
},
|
||||||
|
onLicensesAttributionsSettingsClick = {
|
||||||
|
navController.navigate(LicensesAttributionsSettingsRoute)
|
||||||
|
},
|
||||||
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
|
||||||
{
|
{
|
||||||
appUpdaterController.checkForUpdates(
|
appUpdaterController.checkForUpdates(
|
||||||
|
|
@ -1354,7 +1368,13 @@ private fun MainAppContent(
|
||||||
reuseHandled = true
|
reuseHandled = true
|
||||||
if (launch.manualSelection) return@LaunchedEffect
|
if (launch.manualSelection) return@LaunchedEffect
|
||||||
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
|
||||||
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
|
|
@ -1394,17 +1414,37 @@ private fun MainAppContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val expectedStreamsRequestToken = StreamsRepository.requestToken(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
manualSelection = launch.manualSelection,
|
||||||
|
)
|
||||||
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
|
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
|
||||||
LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
|
LaunchedEffect(
|
||||||
|
streamsUiState.autoPlayStream,
|
||||||
|
streamsUiState.requestToken,
|
||||||
|
expectedStreamsRequestToken,
|
||||||
|
reuseHandled,
|
||||||
|
launch.manualSelection,
|
||||||
|
) {
|
||||||
if (!reuseHandled) return@LaunchedEffect
|
if (!reuseHandled) return@LaunchedEffect
|
||||||
if (launch.manualSelection) return@LaunchedEffect
|
if (launch.manualSelection) return@LaunchedEffect
|
||||||
if (reuseNavigated) return@LaunchedEffect
|
if (reuseNavigated) return@LaunchedEffect
|
||||||
if (autoPlayHandled) return@LaunchedEffect
|
if (autoPlayHandled) return@LaunchedEffect
|
||||||
|
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
|
||||||
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
|
||||||
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
|
||||||
autoPlayHandled = true
|
autoPlayHandled = true
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
url = sourceUrl,
|
url = sourceUrl,
|
||||||
|
|
@ -1484,7 +1524,13 @@ private fun MainAppContent(
|
||||||
if (sourceUrl != null) {
|
if (sourceUrl != null) {
|
||||||
// Persist for Reuse Last Link
|
// Persist for Reuse Last Link
|
||||||
if (playerSettings.streamReuseLastLinkEnabled) {
|
if (playerSettings.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = launch.type,
|
||||||
|
videoId = effectiveVideoId,
|
||||||
|
parentMetaId = launch.parentMetaId,
|
||||||
|
season = launch.seasonNumber,
|
||||||
|
episode = launch.episodeNumber,
|
||||||
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
url = sourceUrl,
|
url = sourceUrl,
|
||||||
|
|
@ -1725,6 +1771,15 @@ private fun MainAppContent(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable<LicensesAttributionsSettingsRoute> { backStackEntry ->
|
||||||
|
val onBack = rememberGuardedPopBackStack(
|
||||||
|
navController = navController,
|
||||||
|
backStackEntry = backStackEntry,
|
||||||
|
)
|
||||||
|
LicensesAttributionsSettingsScreen(
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable<CollectionsRoute> { backStackEntry ->
|
composable<CollectionsRoute> { backStackEntry ->
|
||||||
val onBack = rememberGuardedPopBackStack(
|
val onBack = rememberGuardedPopBackStack(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
|
@ -2003,6 +2058,7 @@ private fun rememberGuardedPopBackStack(
|
||||||
private fun AppTabHost(
|
private fun AppTabHost(
|
||||||
selectedTab: AppScreenTab,
|
selectedTab: AppScreenTab,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
animateHomeCollectionGifs: Boolean = true,
|
||||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
|
@ -2020,6 +2076,7 @@ private fun AppTabHost(
|
||||||
onPluginsSettingsClick: () -> Unit = {},
|
onPluginsSettingsClick: () -> Unit = {},
|
||||||
onAccountSettingsClick: () -> Unit = {},
|
onAccountSettingsClick: () -> Unit = {},
|
||||||
onSupportersContributorsSettingsClick: () -> Unit = {},
|
onSupportersContributorsSettingsClick: () -> Unit = {},
|
||||||
|
onLicensesAttributionsSettingsClick: () -> Unit = {},
|
||||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsSettingsClick: () -> Unit = {},
|
onCollectionsSettingsClick: () -> Unit = {},
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||||
|
|
@ -2033,6 +2090,7 @@ private fun AppTabHost(
|
||||||
AppScreenTab.Home -> {
|
AppScreenTab.Home -> {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
animateCollectionGifs = animateHomeCollectionGifs,
|
||||||
onCatalogClick = onCatalogClick,
|
onCatalogClick = onCatalogClick,
|
||||||
onPosterClick = onPosterClick,
|
onPosterClick = onPosterClick,
|
||||||
onPosterLongClick = onPosterLongClick,
|
onPosterLongClick = onPosterLongClick,
|
||||||
|
|
@ -2072,6 +2130,7 @@ private fun AppTabHost(
|
||||||
onPluginsClick = onPluginsSettingsClick,
|
onPluginsClick = onPluginsSettingsClick,
|
||||||
onAccountClick = onAccountSettingsClick,
|
onAccountClick = onAccountSettingsClick,
|
||||||
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
||||||
|
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
|
||||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onCollectionsClick = onCollectionsSettingsClick,
|
onCollectionsClick = onCollectionsSettingsClick,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.core.storage
|
||||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.catalog.CatalogRepository
|
import com.nuvio.app.features.catalog.CatalogRepository
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
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.MetaScreenSettingsRepository
|
||||||
|
|
@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner {
|
||||||
WatchedRepository.clearLocalState()
|
WatchedRepository.clearLocalState()
|
||||||
ContinueWatchingPreferencesRepository.clearLocalState()
|
ContinueWatchingPreferencesRepository.clearLocalState()
|
||||||
EpisodeReleaseNotificationsRepository.clearLocalState()
|
EpisodeReleaseNotificationsRepository.clearLocalState()
|
||||||
|
CollectionMobileSettingsRepository.clearLocalState()
|
||||||
CollectionRepository.clearLocalState()
|
CollectionRepository.clearLocalState()
|
||||||
ThemeSettingsRepository.clearLocalState()
|
ThemeSettingsRepository.clearLocalState()
|
||||||
PosterCardStyleRepository.clearLocalState()
|
PosterCardStyleRepository.clearLocalState()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.core.auth.AuthRepository
|
import com.nuvio.app.core.auth.AuthRepository
|
||||||
import com.nuvio.app.core.auth.AuthState
|
import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
import com.nuvio.app.features.mdblist.MdbListMetadataService
|
||||||
|
|
@ -158,6 +160,7 @@ object ProfileSettingsSync {
|
||||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||||
|
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
|
||||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||||
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||||
|
|
@ -202,6 +205,7 @@ object ProfileSettingsSync {
|
||||||
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
||||||
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
|
||||||
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
|
collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(),
|
||||||
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(),
|
||||||
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(),
|
||||||
|
|
@ -232,6 +236,9 @@ object ProfileSettingsSync {
|
||||||
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
|
MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload)
|
||||||
MetaScreenSettingsRepository.onProfileChanged()
|
MetaScreenSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
|
CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload)
|
||||||
|
CollectionMobileSettingsRepository.onProfileChanged()
|
||||||
|
|
||||||
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload)
|
||||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||||
|
|
||||||
|
|
@ -251,6 +258,7 @@ object ProfileSettingsSync {
|
||||||
TmdbSettingsRepository.ensureLoaded()
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
MdbListSettingsRepository.ensureLoaded()
|
MdbListSettingsRepository.ensureLoaded()
|
||||||
MetaScreenSettingsRepository.ensureLoaded()
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
|
CollectionMobileSettingsRepository.ensureLoaded()
|
||||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||||
TraktSettingsRepository.ensureLoaded()
|
TraktSettingsRepository.ensureLoaded()
|
||||||
TraktCommentsSettings.ensureLoaded()
|
TraktCommentsSettings.ensureLoaded()
|
||||||
|
|
@ -272,6 +280,7 @@ object ProfileSettingsSync {
|
||||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||||
|
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
|
||||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||||
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||||
|
|
@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures(
|
||||||
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
@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("collection_mobile_settings_payload") val collectionMobileSettingsPayload: 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_settings_payload") val traktSettingsPayload: String = "",
|
||||||
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
@SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()),
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
|
||||||
onSecondary = palette.onSecondaryVariant,
|
onSecondary = palette.onSecondaryVariant,
|
||||||
background = if (amoled) Color.Black else palette.background,
|
background = if (amoled) Color.Black else palette.background,
|
||||||
onBackground = Color(0xFFF5F7F8),
|
onBackground = Color(0xFFF5F7F8),
|
||||||
surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated,
|
surface = palette.backgroundElevated,
|
||||||
onSurface = Color(0xFFF5F7F8),
|
onSurface = Color(0xFFF5F7F8),
|
||||||
surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard,
|
surfaceVariant = palette.backgroundCard,
|
||||||
onSurfaceVariant = Color(0xFF969CA3),
|
onSurfaceVariant = Color(0xFF969CA3),
|
||||||
outline = Color(0xFF252A2A),
|
outline = Color(0xFF252A2A),
|
||||||
error = Color(0xFFE36A8A),
|
error = Color(0xFFE36A8A),
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl(
|
||||||
): String {
|
): String {
|
||||||
val encodedId = id.encodeAddonPathSegment()
|
val encodedId = id.encodeAddonPathSegment()
|
||||||
val baseUrl = addonTransportBaseUrl(manifestUrl)
|
val baseUrl = addonTransportBaseUrl(manifestUrl)
|
||||||
return if (extraPathSegment.isNullOrEmpty()) {
|
val query = manifestUrl.substringAfter("?", "").let { query ->
|
||||||
|
if (query.isBlank()) "" else "?$query"
|
||||||
|
}
|
||||||
|
val resourceUrl = if (extraPathSegment.isNullOrEmpty()) {
|
||||||
"$baseUrl/$resource/$type/$encodedId.json"
|
"$baseUrl/$resource/$type/$encodedId.json"
|
||||||
} else {
|
} else {
|
||||||
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
|
"$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
|
||||||
}
|
}
|
||||||
|
return resourceUrl + query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,10 +195,10 @@ object CollectionEditorRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateFolderFocusGifEnabled(enabled: Boolean) {
|
fun updateFolderMobileFocusGifEnabled(enabled: Boolean) {
|
||||||
val folder = _uiState.value.editingFolder ?: return
|
val folder = _uiState.value.editingFolder ?: return
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
editingFolder = folder.copy(focusGifEnabled = enabled),
|
editingFolder = folder.copy(mobileFocusGifEnabled = enabled),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,6 +808,8 @@ object CollectionEditorRepository {
|
||||||
folders = state.folders,
|
folders = state.folders,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders)
|
||||||
|
|
||||||
if (state.isNew) {
|
if (state.isNew) {
|
||||||
CollectionRepository.addCollection(collection)
|
CollectionRepository.addCollection(collection)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -702,8 +702,8 @@ private fun FolderEditorPage(
|
||||||
FolderEditorToggleRow(
|
FolderEditorToggleRow(
|
||||||
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
|
title = stringResource(Res.string.collections_editor_show_gif_when_configured),
|
||||||
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
|
subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc),
|
||||||
checked = folder.focusGifEnabled,
|
checked = folder.mobileFocusGifEnabled,
|
||||||
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
|
onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
FolderEditorToggleRow(
|
FolderEditorToggleRow(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package com.nuvio.app.features.collection
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
data class CollectionMobileSettingsUiState(
|
||||||
|
val folderGifOverrides: Map<String, Boolean> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
object CollectionMobileSettingsRepository {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState())
|
||||||
|
val uiState: StateFlow<CollectionMobileSettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
fun ensureLoaded() {
|
||||||
|
if (hasLoaded) return
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onProfileChanged() {
|
||||||
|
loadFromDisk()
|
||||||
|
CollectionRepository.onMobileSettingsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLocalState() {
|
||||||
|
hasLoaded = false
|
||||||
|
_uiState.value = CollectionMobileSettingsUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFolderGifEnabled(collectionId: String, folderId: String): Boolean {
|
||||||
|
ensureLoaded()
|
||||||
|
return _uiState.value.folderGifOverrides[folderKey(collectionId, folderId)] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyToCollections(collections: List<Collection>): List<Collection> {
|
||||||
|
ensureLoaded()
|
||||||
|
return collections.map(::applyToCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyToCollection(collection: Collection): Collection {
|
||||||
|
ensureLoaded()
|
||||||
|
return collection.copy(
|
||||||
|
folders = collection.folders.map { folder ->
|
||||||
|
folder.copy(
|
||||||
|
mobileFocusGifEnabled = isFolderGifEnabled(
|
||||||
|
collectionId = collection.id,
|
||||||
|
folderId = folder.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceCollectionFolderGifSettings(collectionId: String, folders: List<CollectionFolder>) {
|
||||||
|
ensureLoaded()
|
||||||
|
val collectionPrefix = "${collectionId.trim()}$FolderKeySeparator"
|
||||||
|
val next = _uiState.value.folderGifOverrides
|
||||||
|
.filterKeys { key -> !key.startsWith(collectionPrefix) }
|
||||||
|
.toMutableMap()
|
||||||
|
folders.forEach { folder ->
|
||||||
|
val key = folderKey(collectionId, folder.id)
|
||||||
|
if (folder.mobileFocusGifEnabled) {
|
||||||
|
next.remove(key)
|
||||||
|
} else {
|
||||||
|
next[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.value = CollectionMobileSettingsUiState(folderGifOverrides = next)
|
||||||
|
persist()
|
||||||
|
CollectionRepository.onMobileSettingsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
hasLoaded = true
|
||||||
|
|
||||||
|
val payload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim()
|
||||||
|
if (payload.isEmpty()) {
|
||||||
|
_uiState.value = CollectionMobileSettingsUiState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val stored = runCatching {
|
||||||
|
json.decodeFromString<StoredCollectionMobileSettingsPayload>(payload)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
_uiState.value = CollectionMobileSettingsUiState(
|
||||||
|
folderGifOverrides = stored
|
||||||
|
?.folderGifOverrides
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull { item ->
|
||||||
|
if (item.collectionId.isBlank() || item.folderId.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
folderKey(item.collectionId, item.folderId) to item.enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist() {
|
||||||
|
if (_uiState.value.folderGifOverrides.isEmpty()) {
|
||||||
|
CollectionMobileSettingsStorage.savePayload("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val payload = StoredCollectionMobileSettingsPayload(
|
||||||
|
folderGifOverrides = _uiState.value.folderGifOverrides
|
||||||
|
.mapNotNull { (key, enabled) ->
|
||||||
|
val parts = key.split(FolderKeySeparator, limit = 2)
|
||||||
|
val collectionId = parts.getOrNull(0).orEmpty()
|
||||||
|
val folderId = parts.getOrNull(1).orEmpty()
|
||||||
|
if (collectionId.isBlank() || folderId.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
StoredFolderGifOverride(
|
||||||
|
collectionId = collectionId,
|
||||||
|
folderId = folderId,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy<StoredFolderGifOverride> { it.collectionId }.thenBy { it.folderId }),
|
||||||
|
)
|
||||||
|
CollectionMobileSettingsStorage.savePayload(json.encodeToString(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun folderKey(collectionId: String, folderId: String): String =
|
||||||
|
"${collectionId.trim()}$FolderKeySeparator${folderId.trim()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FolderKeySeparator = "\u001F"
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StoredCollectionMobileSettingsPayload(
|
||||||
|
@SerialName("folder_gif_overrides") val folderGifOverrides: List<StoredFolderGifOverride> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StoredFolderGifOverride(
|
||||||
|
@SerialName("collection_id") val collectionId: String,
|
||||||
|
@SerialName("folder_id") val folderId: String,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.nuvio.app.features.collection
|
||||||
|
|
||||||
|
internal expect object CollectionMobileSettingsStorage {
|
||||||
|
fun loadPayload(): String?
|
||||||
|
fun savePayload(payload: String)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
|
|
||||||
enum class FolderViewMode {
|
enum class FolderViewMode {
|
||||||
TABBED_GRID,
|
TABBED_GRID,
|
||||||
|
|
@ -13,7 +14,7 @@ enum class FolderViewMode {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromString(value: String): FolderViewMode =
|
fun fromString(value: String): FolderViewMode =
|
||||||
when {
|
when {
|
||||||
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS
|
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> FOLLOW_LAYOUT
|
||||||
value.equals(ROWS.name, ignoreCase = true) -> ROWS
|
value.equals(ROWS.name, ignoreCase = true) -> ROWS
|
||||||
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
|
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
|
||||||
else -> TABBED_GRID
|
else -> TABBED_GRID
|
||||||
|
|
@ -168,6 +169,8 @@ data class CollectionFolder(
|
||||||
val coverImageUrl: String? = null,
|
val coverImageUrl: String? = null,
|
||||||
val focusGifUrl: String? = null,
|
val focusGifUrl: String? = null,
|
||||||
val focusGifEnabled: Boolean = true,
|
val focusGifEnabled: Boolean = true,
|
||||||
|
@Transient
|
||||||
|
val mobileFocusGifEnabled: Boolean = true,
|
||||||
val coverEmoji: String? = null,
|
val coverEmoji: String? = null,
|
||||||
val tileShape: String = "poster",
|
val tileShape: String = "poster",
|
||||||
val hideTitle: Boolean = false,
|
val hideTitle: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ object CollectionRepository {
|
||||||
runCatching {
|
runCatching {
|
||||||
val parsed = json.parseToJsonElement(payload)
|
val parsed = json.parseToJsonElement(payload)
|
||||||
rawCollectionsJson = parsed
|
rawCollectionsJson = parsed
|
||||||
_collections.value = json.decodeFromString<List<Collection>>(payload)
|
val decoded = json.decodeFromString<List<Collection>>(payload)
|
||||||
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.e(e) { "Failed to load collections from storage" }
|
log.e(e) { "Failed to load collections from storage" }
|
||||||
}
|
}
|
||||||
|
|
@ -75,14 +76,15 @@ object CollectionRepository {
|
||||||
|
|
||||||
fun addCollection(collection: Collection) {
|
fun addCollection(collection: Collection) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
_collections.value = _collections.value + collection
|
_collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateCollection(collection: Collection) {
|
fun updateCollection(collection: Collection) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
|
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||||
_collections.value = _collections.value.map {
|
_collections.value = _collections.value.map {
|
||||||
if (it.id == collection.id) collection else it
|
if (it.id == collection.id) decorated else it
|
||||||
}
|
}
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +97,7 @@ object CollectionRepository {
|
||||||
|
|
||||||
fun setCollections(collections: List<Collection>) {
|
fun setCollections(collections: List<Collection>) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
_collections.value = collections
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +129,7 @@ object CollectionRepository {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||||
_collections.value = imported
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
|
||||||
persist()
|
persist()
|
||||||
imported
|
imported
|
||||||
}
|
}
|
||||||
|
|
@ -262,10 +264,15 @@ object CollectionRepository {
|
||||||
|
|
||||||
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
||||||
rawCollectionsJson = rawJson
|
rawCollectionsJson = rawJson
|
||||||
_collections.value = collections
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
|
||||||
persist(sync = false)
|
persist(sync = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun onMobileSettingsChanged() {
|
||||||
|
if (!hasLoaded) return
|
||||||
|
_collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ensureLoaded() {
|
private fun ensureLoaded() {
|
||||||
if (!hasLoaded) initialize()
|
if (!hasLoaded) initialize()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,14 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||||
// numbering on Trakt vs multi-season on addon), try global index matching.
|
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||||
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||||
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
|
val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
|
||||||
|
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
|
||||||
|
normalizeSeasonNumber(episode.season)
|
||||||
|
}
|
||||||
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||||
val globalIndex = episodeNumber - 1
|
val globalIndex = episodeNumber - 1
|
||||||
if (globalIndex in sortedEpisodes.indices) {
|
if (globalIndex in mainEpisodes.indices) {
|
||||||
watchedIndex = globalIndex
|
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ data class HomeCatalogSettingsItem(
|
||||||
data class HomeCatalogSettingsUiState(
|
data class HomeCatalogSettingsUiState(
|
||||||
val heroEnabled: Boolean = true,
|
val heroEnabled: Boolean = true,
|
||||||
val hideUnreleasedContent: Boolean = false,
|
val hideUnreleasedContent: Boolean = false,
|
||||||
|
val hideCatalogUnderline: Boolean = false,
|
||||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val signature: String
|
val signature: String
|
||||||
|
|
@ -41,6 +42,8 @@ data class HomeCatalogSettingsUiState(
|
||||||
append('|')
|
append('|')
|
||||||
append(hideUnreleasedContent)
|
append(hideUnreleasedContent)
|
||||||
append('|')
|
append('|')
|
||||||
|
append(hideCatalogUnderline)
|
||||||
|
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}"
|
||||||
|
|
@ -59,6 +62,7 @@ internal data class HomeCatalogPreference(
|
||||||
internal data class HomeCatalogSettingsSnapshot(
|
internal data class HomeCatalogSettingsSnapshot(
|
||||||
val heroEnabled: Boolean,
|
val heroEnabled: Boolean,
|
||||||
val hideUnreleasedContent: Boolean,
|
val hideUnreleasedContent: Boolean,
|
||||||
|
val hideCatalogUnderline: Boolean,
|
||||||
val preferences: Map<String, HomeCatalogPreference>,
|
val preferences: Map<String, HomeCatalogPreference>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,6 +79,7 @@ private data class StoredHomeCatalogPreference(
|
||||||
private data class StoredHomeCatalogSettingsPayload(
|
private data class StoredHomeCatalogSettingsPayload(
|
||||||
val heroEnabled: Boolean = true,
|
val heroEnabled: Boolean = true,
|
||||||
val hideUnreleasedContent: Boolean = false,
|
val hideUnreleasedContent: Boolean = false,
|
||||||
|
val hideCatalogUnderline: Boolean = false,
|
||||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -95,12 +100,14 @@ object HomeCatalogSettingsRepository {
|
||||||
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
|
private var hideUnreleasedContent = false
|
||||||
|
private var hideCatalogUnderline = false
|
||||||
|
|
||||||
fun onProfileChanged() {
|
fun onProfileChanged() {
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
hideUnreleasedContent = false
|
hideUnreleasedContent = false
|
||||||
|
hideCatalogUnderline = false
|
||||||
definitions = emptyList()
|
definitions = emptyList()
|
||||||
collectionDefinitions = emptyList()
|
collectionDefinitions = emptyList()
|
||||||
_uiState.value = HomeCatalogSettingsUiState()
|
_uiState.value = HomeCatalogSettingsUiState()
|
||||||
|
|
@ -113,6 +120,7 @@ object HomeCatalogSettingsRepository {
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
hideUnreleasedContent = false
|
hideUnreleasedContent = false
|
||||||
|
hideCatalogUnderline = false
|
||||||
_uiState.value = HomeCatalogSettingsUiState()
|
_uiState.value = HomeCatalogSettingsUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +152,7 @@ object HomeCatalogSettingsRepository {
|
||||||
return HomeCatalogSettingsSnapshot(
|
return HomeCatalogSettingsSnapshot(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
preferences = preferences.mapValues { (_, value) ->
|
preferences = preferences.mapValues { (_, value) ->
|
||||||
HomeCatalogPreference(
|
HomeCatalogPreference(
|
||||||
customTitle = value.customTitle,
|
customTitle = value.customTitle,
|
||||||
|
|
@ -172,6 +181,14 @@ object HomeCatalogSettingsRepository {
|
||||||
HomeRepository.applyCurrentSettings()
|
HomeRepository.applyCurrentSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHideCatalogUnderline(enabled: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (hideCatalogUnderline == enabled) return
|
||||||
|
hideCatalogUnderline = enabled
|
||||||
|
publish()
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
|
||||||
updatePreference(key) { preference ->
|
updatePreference(key) { preference ->
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|
@ -200,6 +217,7 @@ object HomeCatalogSettingsRepository {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
heroEnabled = true
|
heroEnabled = true
|
||||||
hideUnreleasedContent = false
|
hideUnreleasedContent = false
|
||||||
|
hideCatalogUnderline = false
|
||||||
preferences.clear()
|
preferences.clear()
|
||||||
normalizePreferences()
|
normalizePreferences()
|
||||||
publish()
|
publish()
|
||||||
|
|
@ -246,6 +264,7 @@ object HomeCatalogSettingsRepository {
|
||||||
if (parsedPayload != null) {
|
if (parsedPayload != null) {
|
||||||
heroEnabled = parsedPayload.heroEnabled
|
heroEnabled = parsedPayload.heroEnabled
|
||||||
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||||
|
hideCatalogUnderline = parsedPayload.hideCatalogUnderline
|
||||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||||
publish()
|
publish()
|
||||||
return
|
return
|
||||||
|
|
@ -345,6 +364,7 @@ object HomeCatalogSettingsRepository {
|
||||||
_uiState.value = HomeCatalogSettingsUiState(
|
_uiState.value = HomeCatalogSettingsUiState(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
items = items,
|
items = items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -355,6 +375,7 @@ object HomeCatalogSettingsRepository {
|
||||||
StoredHomeCatalogSettingsPayload(
|
StoredHomeCatalogSettingsPayload(
|
||||||
heroEnabled = heroEnabled,
|
heroEnabled = heroEnabled,
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
items = preferences.values.sortedBy { it.order },
|
items = preferences.values.sortedBy { it.order },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -437,6 +458,7 @@ object HomeCatalogSettingsRepository {
|
||||||
}
|
}
|
||||||
return SyncHomeCatalogPayload(
|
return SyncHomeCatalogPayload(
|
||||||
hideUnreleasedContent = hideUnreleasedContent,
|
hideUnreleasedContent = hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = hideCatalogUnderline,
|
||||||
items = items,
|
items = items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -444,6 +466,7 @@ object HomeCatalogSettingsRepository {
|
||||||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
hideUnreleasedContent = payload.hideUnreleasedContent
|
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||||
|
hideCatalogUnderline = payload.hideCatalogUnderline
|
||||||
if (payload.items.isNotEmpty()) {
|
if (payload.items.isNotEmpty()) {
|
||||||
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
|
||||||
preferences = payload.items.associate { item ->
|
preferences = payload.items.associate { item ->
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ data class SyncCatalogItem(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncHomeCatalogPayload(
|
data class SyncHomeCatalogPayload(
|
||||||
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||||
|
@SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
|
||||||
val items: List<SyncCatalogItem> = emptyList(),
|
val items: List<SyncCatalogItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,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.ContinueWatchingSortMode
|
||||||
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
|
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
|
||||||
|
|
@ -70,6 +71,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
animateCollectionGifs: Boolean = true,
|
||||||
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
|
||||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
|
@ -246,11 +248,14 @@ fun HomeScreen(
|
||||||
visibleContinueWatchingEntries,
|
visibleContinueWatchingEntries,
|
||||||
cachedInProgressItems,
|
cachedInProgressItems,
|
||||||
effectivNextUpItems,
|
effectivNextUpItems,
|
||||||
|
continueWatchingPreferences.sortMode,
|
||||||
) {
|
) {
|
||||||
buildHomeContinueWatchingItems(
|
buildHomeContinueWatchingItems(
|
||||||
visibleEntries = visibleContinueWatchingEntries,
|
visibleEntries = visibleContinueWatchingEntries,
|
||||||
cachedInProgressByVideoId = cachedInProgressItems,
|
cachedInProgressByVideoId = cachedInProgressItems,
|
||||||
nextUpItemsBySeries = effectivNextUpItems,
|
nextUpItemsBySeries = effectivNextUpItems,
|
||||||
|
sortMode = continueWatchingPreferences.sortMode,
|
||||||
|
todayIsoDate = CurrentDateProvider.todayIsoDate(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val availableManifests = remember(addonsUiState.addons) {
|
val availableManifests = remember(addonsUiState.addons) {
|
||||||
|
|
@ -403,6 +408,11 @@ fun HomeScreen(
|
||||||
val enabledHomeItems = remember(homeSettingsUiState.items) {
|
val enabledHomeItems = remember(homeSettingsUiState.items) {
|
||||||
homeSettingsUiState.items.filter { it.enabled }
|
homeSettingsUiState.items.filter { it.enabled }
|
||||||
}
|
}
|
||||||
|
val hasRenderableCollectionRows = remember(enabledHomeItems, collectionsMap) {
|
||||||
|
enabledHomeItems.any { item ->
|
||||||
|
item.isCollection && collectionsMap[item.key] != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||||
|
|
@ -465,7 +475,7 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
addonsUiState.addons.none { it.manifest != null } -> {
|
addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> {
|
||||||
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
|
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
|
|
@ -490,7 +500,7 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
|
homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> {
|
||||||
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
|
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
HomeContinueWatchingSection(
|
HomeContinueWatchingSection(
|
||||||
|
|
@ -512,7 +522,8 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
|
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
|
||||||
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> {
|
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) &&
|
||||||
|
!hasRenderableCollectionRows -> {
|
||||||
item {
|
item {
|
||||||
if (networkStatusUiState.isOfflineLike) {
|
if (networkStatusUiState.isOfflineLike) {
|
||||||
NuvioNetworkOfflineCard(
|
NuvioNetworkOfflineCard(
|
||||||
|
|
@ -560,6 +571,7 @@ fun HomeScreen(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
sectionPadding = homeSectionPadding,
|
sectionPadding = homeSectionPadding,
|
||||||
|
animateGifs = animateCollectionGifs,
|
||||||
onFolderClick = onFolderClick,
|
onFolderClick = onFolderClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -631,6 +643,8 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
visibleEntries: List<WatchProgressEntry>,
|
visibleEntries: List<WatchProgressEntry>,
|
||||||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||||
|
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
||||||
|
todayIsoDate: String = "",
|
||||||
): List<ContinueWatchingItem> {
|
): List<ContinueWatchingItem> {
|
||||||
val inProgressSeriesIds = visibleEntries
|
val inProgressSeriesIds = visibleEntries
|
||||||
.asSequence()
|
.asSequence()
|
||||||
|
|
@ -639,7 +653,7 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
.filter(String::isNotBlank)
|
.filter(String::isNotBlank)
|
||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
return buildList {
|
val candidates = buildList {
|
||||||
addAll(
|
addAll(
|
||||||
visibleEntries.map { entry ->
|
visibleEntries.map { entry ->
|
||||||
val liveItem = entry.toContinueWatchingItem()
|
val liveItem = entry.toContinueWatchingItem()
|
||||||
|
|
@ -661,13 +675,62 @@ internal fun buildHomeContinueWatchingItems(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate by series/content id first (order-stable)
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
val deduplicated = candidates
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
|
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
|
||||||
.thenByDescending { it.isProgressEntry },
|
.thenByDescending { it.isProgressEntry },
|
||||||
)
|
)
|
||||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||||
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
.filter { candidate ->
|
||||||
|
val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId }
|
||||||
|
seen.add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (sortMode) {
|
||||||
|
ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item)
|
||||||
|
ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyStreamingStyleSort(
|
||||||
|
candidates: List<HomeContinueWatchingCandidate>,
|
||||||
|
todayIsoDate: String,
|
||||||
|
): List<ContinueWatchingItem> {
|
||||||
|
val (released, unreleased) = candidates.partition { candidate ->
|
||||||
|
val item = candidate.item
|
||||||
|
if (!item.isNextUp) {
|
||||||
|
true // in-progress items are always "released"
|
||||||
|
} else {
|
||||||
|
val itemReleased = item.released
|
||||||
|
if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) {
|
||||||
|
true // no date info → treat as released
|
||||||
|
} else {
|
||||||
|
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Released: most recently watched first (already sorted by dedup pass)
|
||||||
|
val sortedReleased = released.map(HomeContinueWatchingCandidate::item)
|
||||||
|
|
||||||
|
// Unaired: soonest air date first; unknown dates go to the end
|
||||||
|
val sortedUnreleased = unreleased
|
||||||
|
.sortedWith { a, b ->
|
||||||
|
val dateA = a.item.released?.takeIf { it.isNotBlank() }
|
||||||
|
val dateB = b.item.released?.takeIf { it.isNotBlank() }
|
||||||
|
when {
|
||||||
|
dateA == null && dateB == null -> 0
|
||||||
|
dateA == null -> 1
|
||||||
|
dateB == null -> -1
|
||||||
|
else -> dateA.compareTo(dateB)
|
||||||
|
}
|
||||||
|
}
|
||||||
.map(HomeContinueWatchingCandidate::item)
|
.map(HomeContinueWatchingCandidate::item)
|
||||||
|
|
||||||
|
return sortedReleased + sortedUnreleased
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CompletedSeriesCandidate(
|
private data class CompletedSeriesCandidate(
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
|
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.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
|
|
@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent(
|
||||||
onPosterLongClick: ((MetaPreview) -> Unit)?,
|
onPosterLongClick: ((MetaPreview) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
|
val homeCatalogSettings by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
title = section.title,
|
title = section.title,
|
||||||
|
|
@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headerHorizontalPadding = sectionPadding,
|
headerHorizontalPadding = sectionPadding,
|
||||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||||
onViewAllClick = onViewAllClick,
|
onViewAllClick = onViewAllClick,
|
||||||
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
||||||
key = { item -> item.stableKey() },
|
key = { item -> item.stableKey() },
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
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.remember
|
||||||
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
|
||||||
|
|
@ -23,6 +25,7 @@ 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 androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||||
|
|
@ -30,6 +33,7 @@ import com.nuvio.app.core.ui.posterCardClickable
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
import com.nuvio.app.features.collection.Collection
|
import com.nuvio.app.features.collection.Collection
|
||||||
import com.nuvio.app.features.collection.CollectionFolder
|
import com.nuvio.app.features.collection.CollectionFolder
|
||||||
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -37,6 +41,7 @@ fun HomeCollectionRowSection(
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sectionPadding: Dp? = null,
|
sectionPadding: Dp? = null,
|
||||||
|
animateGifs: Boolean = true,
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (collection.folders.isEmpty()) return
|
if (collection.folders.isEmpty()) return
|
||||||
|
|
@ -46,6 +51,7 @@ fun HomeCollectionRowSection(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
sectionPadding = sectionPadding,
|
sectionPadding = sectionPadding,
|
||||||
|
animateGifs = animateGifs,
|
||||||
onFolderClick = onFolderClick,
|
onFolderClick = onFolderClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -54,6 +60,7 @@ fun HomeCollectionRowSection(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||||
|
animateGifs = animateGifs,
|
||||||
onFolderClick = onFolderClick,
|
onFolderClick = onFolderClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -65,18 +72,26 @@ private fun HomeCollectionRowSectionContent(
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
sectionPadding: Dp,
|
sectionPadding: Dp,
|
||||||
|
animateGifs: Boolean,
|
||||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val homeCatalogSettings by remember {
|
||||||
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
|
HomeCatalogSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
NuvioShelfSection(
|
NuvioShelfSection(
|
||||||
title = collection.title,
|
title = collection.title,
|
||||||
entries = collection.folders,
|
entries = collection.folders,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headerHorizontalPadding = sectionPadding,
|
headerHorizontalPadding = sectionPadding,
|
||||||
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
rowContentPadding = PaddingValues(horizontal = sectionPadding),
|
||||||
|
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||||
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
|
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
|
||||||
) { folder ->
|
) { folder ->
|
||||||
CollectionFolderCard(
|
CollectionFolderCard(
|
||||||
folder = folder,
|
folder = folder,
|
||||||
|
animateGifs = animateGifs,
|
||||||
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
|
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +101,7 @@ private fun HomeCollectionRowSectionContent(
|
||||||
private fun CollectionFolderCard(
|
private fun CollectionFolderCard(
|
||||||
folder: CollectionFolder,
|
folder: CollectionFolder,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
animateGifs: Boolean = true,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
|
|
@ -138,7 +154,7 @@ private fun CollectionFolderCard(
|
||||||
contentDescription = folder.title,
|
contentDescription = folder.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl),
|
animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
!folder.coverEmoji.isNullOrBlank() -> {
|
!folder.coverEmoji.isNullOrBlank() -> {
|
||||||
|
|
@ -180,7 +196,7 @@ private fun CollectionFolderCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
|
private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? {
|
||||||
return if (folder.focusGifEnabled) {
|
return if (folder.mobileFocusGifEnabled) {
|
||||||
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
|
firstNonBlank(folder.focusGifUrl, folder.coverImageUrl)
|
||||||
} else {
|
} else {
|
||||||
firstNonBlank(folder.coverImageUrl)
|
firstNonBlank(folder.coverImageUrl)
|
||||||
|
|
@ -196,5 +212,5 @@ private fun isAnimatedCollectionFolderImage(
|
||||||
imageUrl: String,
|
imageUrl: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
|
val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false
|
||||||
return folder.focusGifEnabled && imageUrl == gifUrl
|
return folder.mobileFocusGifEnabled && imageUrl == gifUrl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,14 @@ object LibraryRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun removeFromList(item: LibraryItem, listKey: String) {
|
||||||
|
val desiredMembership = libraryMembershipWithRemovedList(
|
||||||
|
currentMembership = getMembershipSnapshot(item),
|
||||||
|
listKey = listKey,
|
||||||
|
)
|
||||||
|
applyMembershipChanges(item, desiredMembership)
|
||||||
|
}
|
||||||
|
|
||||||
private fun pushToServer() {
|
private fun pushToServer() {
|
||||||
syncScope.launch {
|
syncScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -417,6 +425,14 @@ internal fun libraryMembershipWithLocal(
|
||||||
putAll(traktMembership)
|
putAll(traktMembership)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun libraryMembershipWithRemovedList(
|
||||||
|
currentMembership: Map<String, Boolean>,
|
||||||
|
listKey: String,
|
||||||
|
): Map<String, Boolean> =
|
||||||
|
currentMembership.toMutableMap().apply {
|
||||||
|
this[listKey] = false
|
||||||
|
}
|
||||||
|
|
||||||
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
||||||
id = contentId,
|
id = contentId,
|
||||||
type = contentType,
|
type = contentType,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ 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.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
|
import com.nuvio.app.core.ui.NuvioToastController
|
||||||
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
import com.nuvio.app.core.ui.NuvioViewAllPillSize
|
||||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||||
|
|
@ -33,8 +34,15 @@ import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
import com.nuvio.app.features.profiles.ProfileRepository
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
private data class LibraryRemovalTarget(
|
||||||
|
val item: LibraryItem,
|
||||||
|
val listKey: String? = null,
|
||||||
|
val listTitle: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -47,7 +55,7 @@ fun LibraryScreen(
|
||||||
LibraryRepository.uiState
|
LibraryRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
// var pendingRemovalItem by remember { mutableStateOf<LibraryItem?>(null) }
|
var pendingRemovalTarget by remember { mutableStateOf<LibraryRemovalTarget?>(null) }
|
||||||
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
|
||||||
|
|
@ -167,8 +175,16 @@ fun LibraryScreen(
|
||||||
sections = uiState.sections,
|
sections = uiState.sections,
|
||||||
onPosterClick = onPosterClick,
|
onPosterClick = onPosterClick,
|
||||||
onSectionViewAllClick = onSectionViewAllClick,
|
onSectionViewAllClick = onSectionViewAllClick,
|
||||||
onPosterLongClick = { item ->
|
onPosterLongClick = { item, section ->
|
||||||
onPosterLongClick?.invoke(item)
|
pendingRemovalTarget = if (isTraktSource) {
|
||||||
|
LibraryRemovalTarget(
|
||||||
|
item = item,
|
||||||
|
listKey = section.type,
|
||||||
|
listTitle = section.displayTitle,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LibraryRemovalTarget(item = item)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -177,17 +193,38 @@ fun LibraryScreen(
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
title = stringResource(Res.string.library_remove_title),
|
title = stringResource(Res.string.library_remove_title),
|
||||||
message = pendingRemovalItem?.let {
|
message = pendingRemovalTarget?.let { target ->
|
||||||
stringResource(Res.string.library_remove_message, it.name)
|
val listTitle = target.listTitle
|
||||||
|
if (listTitle.isNullOrBlank()) {
|
||||||
|
stringResource(Res.string.library_remove_message, target.item.name)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.library_remove_from_list_message, target.item.name, listTitle)
|
||||||
|
}
|
||||||
}.orEmpty(),
|
}.orEmpty(),
|
||||||
isVisible = pendingRemovalItem != null,
|
isVisible = pendingRemovalTarget != null,
|
||||||
confirmText = stringResource(Res.string.library_remove_confirm),
|
confirmText = stringResource(Res.string.library_remove_confirm),
|
||||||
dismissText = stringResource(Res.string.action_cancel),
|
dismissText = stringResource(Res.string.action_cancel),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
pendingRemovalItem?.id?.let(LibraryRepository::remove)
|
val target = pendingRemovalTarget
|
||||||
pendingRemovalItem = null
|
pendingRemovalTarget = null
|
||||||
|
target?.let {
|
||||||
|
val listKey = target.listKey
|
||||||
|
if (listKey.isNullOrBlank()) {
|
||||||
|
LibraryRepository.remove(target.item.id)
|
||||||
|
} else {
|
||||||
|
coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
LibraryRepository.removeFromList(target.item, listKey)
|
||||||
|
}.onFailure { error ->
|
||||||
|
NuvioToastController.show(
|
||||||
|
error.message ?: getString(Res.string.trakt_lists_update_failed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDismiss = { pendingRemovalItem = null },
|
onDismiss = { pendingRemovalTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +232,7 @@ private fun LazyListScope.librarySections(
|
||||||
sections: List<LibrarySection>,
|
sections: List<LibrarySection>,
|
||||||
onPosterClick: ((LibraryItem) -> Unit)?,
|
onPosterClick: ((LibraryItem) -> Unit)?,
|
||||||
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
||||||
onPosterLongClick: (LibraryItem) -> Unit,
|
onPosterLongClick: (LibraryItem, LibrarySection) -> Unit,
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = sections,
|
items = sections,
|
||||||
|
|
@ -218,7 +255,7 @@ private fun LazyListScope.librarySections(
|
||||||
HomePosterCard(
|
HomePosterCard(
|
||||||
item = item.toMetaPreview(),
|
item = item.toMetaPreview(),
|
||||||
onClick = onPosterClick?.let { { it(item) } },
|
onClick = onPosterClick?.let { { it(item) } },
|
||||||
onLongClick = { onPosterLongClick(item) },
|
onLongClick = { onPosterLongClick(item, section) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -357,9 +357,10 @@ fun PlayerScreen(
|
||||||
.coerceIn(0f, 100f)
|
.coerceIn(0f, 100f)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
|
suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
|
||||||
contentType = contentType ?: parentMetaType,
|
contentType = contentType ?: parentMetaType,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
|
videoId = activeVideoId,
|
||||||
title = title,
|
title = title,
|
||||||
seasonNumber = activeSeasonNumber,
|
seasonNumber = activeSeasonNumber,
|
||||||
episodeNumber = activeEpisodeNumber,
|
episodeNumber = activeEpisodeNumber,
|
||||||
|
|
@ -367,11 +368,15 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
fun emitTraktScrobbleStart() {
|
fun emitTraktScrobbleStart() {
|
||||||
val item = currentTraktScrobbleItem() ?: return
|
|
||||||
if (hasRequestedScrobbleStartForCurrentItem) return
|
if (hasRequestedScrobbleStartForCurrentItem) return
|
||||||
hasRequestedScrobbleStartForCurrentItem = true
|
hasRequestedScrobbleStartForCurrentItem = true
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val item = currentTraktScrobbleItem()
|
||||||
|
if (item == null) {
|
||||||
|
hasRequestedScrobbleStartForCurrentItem = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
TraktScrobbleRepository.scrobbleStart(
|
TraktScrobbleRepository.scrobbleStart(
|
||||||
item = item,
|
item = item,
|
||||||
progressPercent = currentPlaybackProgressPercent(),
|
progressPercent = currentPlaybackProgressPercent(),
|
||||||
|
|
@ -380,12 +385,12 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun emitTraktScrobbleStop(progressPercent: Float? = null) {
|
fun emitTraktScrobbleStop(progressPercent: Float? = null) {
|
||||||
val item = currentTraktScrobbleItem() ?: return
|
|
||||||
val provided = progressPercent
|
val provided = progressPercent
|
||||||
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
|
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
|
||||||
|
|
||||||
val percent = provided ?: currentPlaybackProgressPercent()
|
val percent = provided ?: currentPlaybackProgressPercent()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val item = currentTraktScrobbleItem() ?: return@launch
|
||||||
TraktScrobbleRepository.scrobbleStop(
|
TraktScrobbleRepository.scrobbleStop(
|
||||||
item = item,
|
item = item,
|
||||||
progressPercent = percent,
|
progressPercent = percent,
|
||||||
|
|
@ -786,8 +791,11 @@ fun PlayerScreen(
|
||||||
flushWatchProgress()
|
flushWatchProgress()
|
||||||
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
|
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
contentType ?: parentMetaType,
|
type = contentType ?: parentMetaType,
|
||||||
activeVideoId!!,
|
videoId = activeVideoId!!,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
)
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
|
|
@ -846,8 +854,11 @@ fun PlayerScreen(
|
||||||
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
|
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
|
||||||
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
|
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
contentType ?: parentMetaType,
|
type = contentType ?: parentMetaType,
|
||||||
epVideoId,
|
videoId = epVideoId,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
season = episode.season,
|
||||||
|
episode = episode.episode,
|
||||||
)
|
)
|
||||||
StreamLinkCacheRepository.save(
|
StreamLinkCacheRepository.save(
|
||||||
contentKey = cacheKey,
|
contentKey = cacheKey,
|
||||||
|
|
@ -1449,12 +1460,15 @@ fun PlayerScreen(
|
||||||
totalDy += delta.y
|
totalDy += delta.y
|
||||||
|
|
||||||
if (gestureMode == null) {
|
if (gestureMode == null) {
|
||||||
|
val holdToSpeedActive = isHoldToSpeedGestureActiveState.value
|
||||||
val horizontalDominant =
|
val horizontalDominant =
|
||||||
!isHoldToSpeedGestureActiveState.value &&
|
!holdToSpeedActive &&
|
||||||
abs(totalDx) > viewConfiguration.touchSlop &&
|
abs(totalDx) > viewConfiguration.touchSlop &&
|
||||||
abs(totalDx) > abs(totalDy)
|
abs(totalDx) > abs(totalDy)
|
||||||
val verticalDominant =
|
val verticalDominant =
|
||||||
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
|
!holdToSpeedActive &&
|
||||||
|
abs(totalDy) > viewConfiguration.touchSlop &&
|
||||||
|
abs(totalDy) > abs(totalDx)
|
||||||
|
|
||||||
gestureMode = when {
|
gestureMode = when {
|
||||||
horizontalDominant -> {
|
horizontalDominant -> {
|
||||||
|
|
@ -1555,8 +1569,11 @@ fun PlayerScreen(
|
||||||
val currentVideoId = activeVideoId
|
val currentVideoId = activeVideoId
|
||||||
if (currentVideoId != null) {
|
if (currentVideoId != null) {
|
||||||
val cacheKey = StreamLinkCacheRepository.contentKey(
|
val cacheKey = StreamLinkCacheRepository.contentKey(
|
||||||
contentType ?: parentMetaType,
|
type = contentType ?: parentMetaType,
|
||||||
currentVideoId,
|
videoId = currentVideoId,
|
||||||
|
parentMetaId = parentMetaId,
|
||||||
|
season = activeSeasonNumber,
|
||||||
|
episode = activeEpisodeNumber,
|
||||||
)
|
)
|
||||||
StreamLinkCacheRepository.remove(cacheKey)
|
StreamLinkCacheRepository.remove(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.nuvio.app.features.profiles
|
||||||
|
|
||||||
|
internal expect object ProfileHoverHapticFeedback {
|
||||||
|
fun prepare()
|
||||||
|
fun perform()
|
||||||
|
fun release()
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.auth.isAnonymous
|
import com.nuvio.app.core.auth.isAnonymous
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
import com.nuvio.app.core.network.SupabaseProvider
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
|
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||||
import com.nuvio.app.features.collection.CollectionRepository
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
|
|
@ -156,6 +157,7 @@ object ProfileRepository {
|
||||||
TraktAuthRepository.onProfileChanged()
|
TraktAuthRepository.onProfileChanged()
|
||||||
SearchHistoryRepository.onProfileChanged()
|
SearchHistoryRepository.onProfileChanged()
|
||||||
CollectionRepository.onProfileChanged()
|
CollectionRepository.onProfileChanged()
|
||||||
|
CollectionMobileSettingsRepository.onProfileChanged()
|
||||||
DownloadsRepository.onProfileChanged()
|
DownloadsRepository.onProfileChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -64,6 +70,7 @@ import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.isIos
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
|
@ -97,6 +104,52 @@ fun ProfileSwitcherTab(
|
||||||
// Keep popup composed while exit animation plays
|
// Keep popup composed while exit animation plays
|
||||||
var popupVisible by remember { mutableStateOf(false) }
|
var popupVisible by remember { mutableStateOf(false) }
|
||||||
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||||
|
var dragTargetProfileIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var triggerCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
|
val profileBubbleBounds = remember(profiles.map { it.profileIndex }) {
|
||||||
|
mutableStateMapOf<Int, Rect>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun performProfileHoldHaptic() {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun performProfileHoverHaptic() {
|
||||||
|
if (isIos) {
|
||||||
|
ProfileHoverHapticFeedback.perform()
|
||||||
|
} else {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDragTarget(localPosition: Offset) {
|
||||||
|
val trigger = triggerCoordinates ?: return
|
||||||
|
val windowPosition = trigger.localToWindow(localPosition)
|
||||||
|
val nextTargetProfileIndex = profileBubbleBounds.entries
|
||||||
|
.firstOrNull { (_, bounds) -> bounds.contains(windowPosition) }
|
||||||
|
?.key
|
||||||
|
if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) {
|
||||||
|
performProfileHoverHaptic()
|
||||||
|
}
|
||||||
|
dragTargetProfileIndex = nextTargetProfileIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseProfile(profile: NuvioProfile) {
|
||||||
|
if (profile.pinEnabled) {
|
||||||
|
pinProfile = profile
|
||||||
|
} else {
|
||||||
|
onProfileSelected(profile)
|
||||||
|
showPopup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseDragTarget() {
|
||||||
|
val profile = profiles.firstOrNull { it.profileIndex == dragTargetProfileIndex }
|
||||||
|
dragTargetProfileIndex = null
|
||||||
|
if (profile != null) {
|
||||||
|
chooseProfile(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Popup entrance/exit animation
|
// Popup entrance/exit animation
|
||||||
val popupAlpha = remember { Animatable(0f) }
|
val popupAlpha = remember { Animatable(0f) }
|
||||||
|
|
@ -126,6 +179,7 @@ fun ProfileSwitcherTab(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
ProfileHoverHapticFeedback.release()
|
||||||
// Animate out
|
// Animate out
|
||||||
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
|
launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) }
|
||||||
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
|
launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) }
|
||||||
|
|
@ -134,21 +188,41 @@ fun ProfileSwitcherTab(
|
||||||
// Remove from composition after animation completes
|
// Remove from composition after animation completes
|
||||||
popupVisible = false
|
popupVisible = false
|
||||||
pinProfile = null
|
pinProfile = null
|
||||||
|
dragTargetProfileIndex = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.onGloballyPositioned { triggerCoordinates = it }
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
.pointerInput(profiles) {
|
.pointerInput(profiles) {
|
||||||
detectTapGestures(
|
detectDragGesturesAfterLongPress(
|
||||||
onTap = { onClick() },
|
onDragStart = { startOffset ->
|
||||||
onLongPress = {
|
|
||||||
if (profiles.isNotEmpty()) {
|
if (profiles.isNotEmpty()) {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
performProfileHoldHaptic()
|
||||||
|
ProfileHoverHapticFeedback.prepare()
|
||||||
showPopup = true
|
showPopup = true
|
||||||
|
updateDragTarget(startOffset)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
updateDragTarget(change.position)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
ProfileHoverHapticFeedback.release()
|
||||||
|
chooseDragTarget()
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
ProfileHoverHapticFeedback.release()
|
||||||
|
dragTargetProfileIndex = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|
@ -199,20 +273,20 @@ fun ProfileSwitcherTab(
|
||||||
profile.profileIndex == activeProfile?.profileIndex
|
profile.profileIndex == activeProfile?.profileIndex
|
||||||
val isPinTarget =
|
val isPinTarget =
|
||||||
pinProfile?.profileIndex == profile.profileIndex
|
pinProfile?.profileIndex == profile.profileIndex
|
||||||
|
val isDragTarget =
|
||||||
|
dragTargetProfileIndex == profile.profileIndex
|
||||||
|
|
||||||
PopupProfileBubble(
|
PopupProfileBubble(
|
||||||
profile = profile,
|
profile = profile,
|
||||||
avatars = avatars,
|
avatars = avatars,
|
||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
isSelected = isPinTarget,
|
isSelected = isPinTarget || isDragTarget,
|
||||||
delayMs = index * 50,
|
delayMs = index * 50,
|
||||||
|
onBoundsChanged = { bounds ->
|
||||||
|
profileBubbleBounds[profile.profileIndex] = bounds
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (profile.pinEnabled) {
|
chooseProfile(profile)
|
||||||
pinProfile = profile
|
|
||||||
} else {
|
|
||||||
onProfileSelected(profile)
|
|
||||||
showPopup = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +409,7 @@ private fun PopupProfileBubble(
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
delayMs: Int,
|
delayMs: Int,
|
||||||
|
onBoundsChanged: (Rect) -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
|
val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) }
|
||||||
|
|
@ -363,7 +438,7 @@ private fun PopupProfileBubble(
|
||||||
}
|
}
|
||||||
|
|
||||||
val pressScale by animateFloatAsState(
|
val pressScale by animateFloatAsState(
|
||||||
targetValue = if (isSelected) 1.15f else 1f,
|
targetValue = if (isSelected) 1.08f else 1f,
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
stiffness = Spring.StiffnessLow,
|
stiffness = Spring.StiffnessLow,
|
||||||
|
|
@ -374,6 +449,9 @@ private fun PopupProfileBubble(
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
onBoundsChanged(coordinates.boundsInWindow())
|
||||||
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = itemAlpha.value
|
alpha = itemAlpha.value
|
||||||
scaleX = itemScale.value * pressScale
|
scaleX = itemScale.value * pressScale
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
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.coroutines.joinAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
@ -91,16 +91,57 @@ object SearchRepository {
|
||||||
_uiState.value = SearchUiState(isLoading = true)
|
_uiState.value = SearchUiState(isLoading = true)
|
||||||
|
|
||||||
activeJob = scope.launch {
|
activeJob = scope.launch {
|
||||||
val results = requests.map { request ->
|
val resultChannel = Channel<IndexedSearchResult>(Channel.UNLIMITED)
|
||||||
async {
|
val jobs = requests.mapIndexed { index, request ->
|
||||||
|
launch {
|
||||||
runCatching { request.toSection() }
|
runCatching { request.toSection() }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { section ->
|
||||||
|
resultChannel.send(
|
||||||
|
IndexedSearchResult(
|
||||||
|
index = index,
|
||||||
|
section = section,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
resultChannel.send(
|
||||||
|
IndexedSearchResult(
|
||||||
|
index = index,
|
||||||
|
error = error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.awaitAll()
|
}
|
||||||
|
val closeChannelJob = launch {
|
||||||
|
jobs.joinAll()
|
||||||
|
resultChannel.close()
|
||||||
|
}
|
||||||
|
val results = arrayOfNulls<IndexedSearchResult>(requests.size)
|
||||||
|
|
||||||
val sections = results
|
try {
|
||||||
.mapNotNull { it.getOrNull() }
|
for (result in resultChannel) {
|
||||||
val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
|
results[result.index] = result
|
||||||
val allFailed = results.isNotEmpty() && results.all { it.isFailure }
|
val sections = results.orderedSections()
|
||||||
|
if (sections.isNotEmpty()) {
|
||||||
|
_uiState.value = SearchUiState(
|
||||||
|
isLoading = true,
|
||||||
|
sections = sections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
closeChannelJob.cancel()
|
||||||
|
resultChannel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val completedResults = results.filterNotNull()
|
||||||
|
val sections = results.orderedSections()
|
||||||
|
val firstFailure = completedResults.firstNotNullOfOrNull { it.error?.message }
|
||||||
|
val allFailed = completedResults.isNotEmpty() && completedResults.all { it.error != null }
|
||||||
|
|
||||||
_uiState.value = SearchUiState(
|
_uiState.value = SearchUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -436,6 +477,15 @@ object SearchRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class IndexedSearchResult(
|
||||||
|
val index: Int,
|
||||||
|
val section: HomeCatalogSection? = null,
|
||||||
|
val error: Throwable? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Array<IndexedSearchResult?>.orderedSections(): List<HomeCatalogSection> =
|
||||||
|
mapNotNull { result -> result?.section }
|
||||||
|
|
||||||
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
|
||||||
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
|
||||||
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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.text.style.TextOverflow
|
||||||
|
|
@ -220,7 +221,14 @@ fun SearchScreen(
|
||||||
androidx.compose.foundation.layout.Column(
|
androidx.compose.foundation.layout.Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
title = headerTitle,
|
title = headerTitle,
|
||||||
|
|
@ -277,53 +285,66 @@ fun SearchScreen(
|
||||||
onPosterLongClick = onPosterLongClick,
|
onPosterLongClick = onPosterLongClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
when {
|
val normalizedQuery = query.trim()
|
||||||
uiState.isLoading && uiState.sections.isEmpty() -> {
|
val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
|
||||||
items(2) {
|
when {
|
||||||
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
isWaitingForSearch -> {
|
||||||
|
items(2) {
|
||||||
|
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
uiState.sections.isEmpty() -> {
|
uiState.isLoading && uiState.sections.isEmpty() -> {
|
||||||
item {
|
items(2) {
|
||||||
SearchEmptyStateCard(
|
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
||||||
reason = uiState.emptyStateReason,
|
}
|
||||||
errorMessage = uiState.errorMessage,
|
|
||||||
networkCondition = networkStatusUiState.condition,
|
|
||||||
onRetry = {
|
|
||||||
val normalizedQuery = query.trim()
|
|
||||||
if (normalizedQuery.isNotBlank()) {
|
|
||||||
NetworkStatusRepository.requestRefresh(force = true)
|
|
||||||
SearchRepository.search(
|
|
||||||
query = normalizedQuery,
|
|
||||||
addons = addonsUiState.addons,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
uiState.sections.isEmpty() -> {
|
||||||
items(
|
item {
|
||||||
items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
|
SearchEmptyStateCard(
|
||||||
key = { section -> section.lazyKey },
|
reason = uiState.emptyStateReason,
|
||||||
) { keyedSection ->
|
errorMessage = uiState.errorMessage,
|
||||||
val section = keyedSection.value
|
networkCondition = networkStatusUiState.condition,
|
||||||
HomeCatalogRowSection(
|
onRetry = {
|
||||||
section = section,
|
if (normalizedQuery.isNotBlank()) {
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
NetworkStatusRepository.requestRefresh(force = true)
|
||||||
watchedKeys = watchedUiState.watchedKeys,
|
SearchRepository.search(
|
||||||
onPosterClick = onPosterClick,
|
query = normalizedQuery,
|
||||||
onPosterLongClick = onPosterLongClick,
|
addons = addonsUiState.addons,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = homeSectionPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
items(
|
||||||
|
items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
|
||||||
|
key = { section -> section.lazyKey },
|
||||||
|
) { keyedSection ->
|
||||||
|
val section = keyedSection.value
|
||||||
|
HomeCatalogRowSection(
|
||||||
|
section = section,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
watchedKeys = watchedUiState.watchedKeys,
|
||||||
|
onPosterClick = onPosterClick,
|
||||||
|
onPosterLongClick = onPosterLongClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
item(key = "search_loading_more") {
|
||||||
|
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
|
private fun discoverColumnCountForWidth(screenWidth: Dp): Int =
|
||||||
when {
|
when {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -20,24 +17,17 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.nuvio.app.core.auth.AuthRepository
|
import com.nuvio.app.core.auth.AuthRepository
|
||||||
import com.nuvio.app.core.auth.AuthState
|
import com.nuvio.app.core.auth.AuthState
|
||||||
import com.nuvio.app.core.auth.isAnonymous
|
|
||||||
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
||||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.action_delete
|
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_account
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_account_description
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title
|
|
||||||
import nuvio.composeapp.generated.resources.settings_account_email
|
import nuvio.composeapp.generated.resources.settings_account_email
|
||||||
import nuvio.composeapp.generated.resources.settings_account_not_signed_in
|
import nuvio.composeapp.generated.resources.settings_account_not_signed_in
|
||||||
import nuvio.composeapp.generated.resources.settings_account_sign_out
|
import nuvio.composeapp.generated.resources.settings_account_sign_out
|
||||||
|
|
@ -62,7 +52,6 @@ private fun AccountSettingsBody(
|
||||||
) {
|
) {
|
||||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
|
||||||
var showSignOutConfirm by remember { mutableStateOf(false) }
|
var showSignOutConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
|
@ -131,35 +120,6 @@ private fun AccountSettingsBody(
|
||||||
text = stringResource(Res.string.settings_account_sign_out),
|
text = stringResource(Res.string.settings_account_sign_out),
|
||||||
onClick = { showSignOutConfirm = true },
|
onClick = { showSignOutConfirm = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (authState is AuthState.Authenticated && !(authState as AuthState.Authenticated).isAnonymous) {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { showDeleteConfirm = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(52.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f),
|
|
||||||
contentColor = MaterialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.settings_account_delete_account),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.settings_account_delete_account_description),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioStatusModal(
|
NuvioStatusModal(
|
||||||
|
|
@ -174,17 +134,4 @@ private fun AccountSettingsBody(
|
||||||
},
|
},
|
||||||
onDismiss = { showSignOutConfirm = false },
|
onDismiss = { showSignOutConfirm = false },
|
||||||
)
|
)
|
||||||
|
|
||||||
NuvioStatusModal(
|
|
||||||
title = stringResource(Res.string.settings_account_delete_confirm_title),
|
|
||||||
message = stringResource(Res.string.settings_account_delete_confirm_message),
|
|
||||||
isVisible = showDeleteConfirm,
|
|
||||||
confirmText = stringResource(Res.string.action_delete),
|
|
||||||
dismissText = stringResource(Res.string.action_cancel),
|
|
||||||
onConfirm = {
|
|
||||||
showDeleteConfirm = false
|
|
||||||
scope.launch { AuthRepository.deleteAccount() }
|
|
||||||
},
|
|
||||||
onDismiss = { showDeleteConfirm = false },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import nuvio.composeapp.generated.resources.lang_turkish
|
||||||
import nuvio.composeapp.generated.resources.lang_italian
|
import nuvio.composeapp.generated.resources.lang_italian
|
||||||
import nuvio.composeapp.generated.resources.lang_greek
|
import nuvio.composeapp.generated.resources.lang_greek
|
||||||
import nuvio.composeapp.generated.resources.lang_polish
|
import nuvio.composeapp.generated.resources.lang_polish
|
||||||
|
import nuvio.composeapp.generated.resources.lang_czech
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
|
||||||
enum class AppLanguage(
|
enum class AppLanguage(
|
||||||
|
|
@ -25,6 +26,7 @@ enum class AppLanguage(
|
||||||
ITALIAN("it", Res.string.lang_italian),
|
ITALIAN("it", Res.string.lang_italian),
|
||||||
GREEK("el", Res.string.lang_greek),
|
GREEK("el", Res.string.lang_greek),
|
||||||
POLISH("pl", Res.string.lang_polish),
|
POLISH("pl", Res.string.lang_polish),
|
||||||
|
CZECH("cs", Res.string.lang_czech),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
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.rounded.Check
|
||||||
import androidx.compose.material.icons.rounded.CheckCircle
|
import androidx.compose.material.icons.rounded.CheckCircle
|
||||||
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
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
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
|
@ -25,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
|
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
||||||
|
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
|
||||||
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
|
||||||
|
|
@ -34,10 +44,16 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_show_unai
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
|
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_sort_order
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
|
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
|
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
|
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
|
||||||
|
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
|
||||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
|
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
|
||||||
|
|
@ -58,6 +74,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
showUnairedNextUp: Boolean,
|
showUnairedNextUp: Boolean,
|
||||||
blurNextUp: Boolean,
|
blurNextUp: Boolean,
|
||||||
showResumePromptOnLaunch: Boolean,
|
showResumePromptOnLaunch: Boolean,
|
||||||
|
sortMode: ContinueWatchingSortMode,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
|
|
@ -145,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
var showSortModeSheet by remember { mutableStateOf(false) }
|
||||||
|
SettingsSection(
|
||||||
|
title = stringResource(Res.string.settings_continue_watching_section_sort_order),
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
val currentModeLabel = stringResource(
|
||||||
|
when (sortMode) {
|
||||||
|
ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default
|
||||||
|
ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = stringResource(Res.string.settings_continue_watching_sort_mode_title),
|
||||||
|
description = currentModeLabel,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = { showSortModeSheet = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSortModeSheet) {
|
||||||
|
ContinueWatchingSortModeDialog(
|
||||||
|
currentMode = sortMode,
|
||||||
|
onModeSelected = { mode ->
|
||||||
|
ContinueWatchingPreferencesRepository.setSortMode(mode)
|
||||||
|
showSortModeSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showSortModeSheet = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -250,3 +300,101 @@ private val ContinueWatchingSectionStyle.descriptionRes: StringResource
|
||||||
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
|
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
|
||||||
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
|
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ContinueWatchingSortModeDialog(
|
||||||
|
currentMode: ContinueWatchingSortMode,
|
||||||
|
onModeSelected: (ContinueWatchingSortMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = listOf(
|
||||||
|
Triple(
|
||||||
|
ContinueWatchingSortMode.DEFAULT,
|
||||||
|
Res.string.settings_continue_watching_sort_mode_default,
|
||||||
|
Res.string.settings_continue_watching_sort_mode_default_desc,
|
||||||
|
),
|
||||||
|
Triple(
|
||||||
|
ContinueWatchingSortMode.STREAMING_STYLE,
|
||||||
|
Res.string.settings_continue_watching_sort_mode_streaming,
|
||||||
|
Res.string.settings_continue_watching_sort_mode_streaming_desc,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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.settings_continue_watching_sort_mode_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
options.forEach { (mode, titleRes, descriptionRes) ->
|
||||||
|
val isSelected = mode == currentMode
|
||||||
|
val containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onModeSelected(mode) },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = containerColor,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(descriptionRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import nuvio.composeapp.generated.resources.layout_hide_unreleased
|
||||||
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
|
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_hide_catalog_underline
|
||||||
|
import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline_description
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
|
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
|
||||||
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
|
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
|
||||||
|
|
@ -65,6 +67,7 @@ internal fun LazyListScope.homescreenSettingsContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
heroEnabled: Boolean,
|
heroEnabled: Boolean,
|
||||||
hideUnreleasedContent: Boolean,
|
hideUnreleasedContent: Boolean,
|
||||||
|
hideCatalogUnderline: Boolean,
|
||||||
items: List<HomeCatalogSettingsItem>,
|
items: List<HomeCatalogSettingsItem>,
|
||||||
) {
|
) {
|
||||||
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
|
||||||
|
|
@ -98,6 +101,14 @@ internal fun LazyListScope.homescreenSettingsContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
|
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = stringResource(Res.string.settings_homescreen_hide_catalog_underline),
|
||||||
|
description = stringResource(Res.string.settings_homescreen_hide_catalog_underline_description),
|
||||||
|
checked = hideCatalogUnderline,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = HomeCatalogSettingsRepository::setHideCatalogUnderline,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ internal enum class IntegrationLogo {
|
||||||
Tmdb,
|
Tmdb,
|
||||||
Trakt,
|
Trakt,
|
||||||
MdbList,
|
MdbList,
|
||||||
|
IntroDb,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
|
import com.nuvio.app.isIos
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
private const val TmdbUrl = "https://www.themoviedb.org"
|
||||||
|
private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/"
|
||||||
|
private const val TraktUrl = "https://trakt.tv"
|
||||||
|
private const val MdbListUrl = "https://mdblist.com"
|
||||||
|
private const val IntroDbUrl = "https://introdb.app/"
|
||||||
|
private const val NuvioRepositoryUrl = "https://github.com/NuvioMedia/NuvioMobile"
|
||||||
|
private const val MpvKitUrl = "https://github.com/mpvkit/MPVKit"
|
||||||
|
private const val ApacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0"
|
||||||
|
|
||||||
|
private data class AttributionItem(
|
||||||
|
val titleRes: StringResource,
|
||||||
|
val bodyRes: StringResource,
|
||||||
|
val logo: IntegrationLogo?,
|
||||||
|
val link: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class LicenseItem(
|
||||||
|
val titleRes: StringResource,
|
||||||
|
val bodyRes: StringResource,
|
||||||
|
val licenseRes: StringResource,
|
||||||
|
val link: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LicensesAttributionsSettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
NuvioScreen(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
stickyHeader {
|
||||||
|
NuvioScreenHeader(
|
||||||
|
title = stringResource(Res.string.compose_settings_page_licenses_attributions),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
licensesAttributionsContent(isTablet = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LazyListScope.licensesAttributionsContent(
|
||||||
|
isTablet: Boolean,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
LicensesAttributionsBody(isTablet = isTablet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LicensesAttributionsBody(
|
||||||
|
isTablet: Boolean,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(if (isTablet) 28.dp else 24.dp),
|
||||||
|
) {
|
||||||
|
PlainSettingsStack(
|
||||||
|
title = stringResource(Res.string.settings_licenses_attributions_section_app),
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
LicenseRow(
|
||||||
|
item = appLicenseItem(),
|
||||||
|
isTablet = isTablet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlainSettingsStack(
|
||||||
|
title = stringResource(Res.string.settings_licenses_attributions_section_data),
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
val items = attributionItems()
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
AttributionRow(
|
||||||
|
item = item,
|
||||||
|
isTablet = isTablet,
|
||||||
|
)
|
||||||
|
if (index != items.lastIndex) {
|
||||||
|
PlainStackDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlainSettingsStack(
|
||||||
|
title = stringResource(Res.string.settings_licenses_attributions_section_playback),
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
LicenseRow(
|
||||||
|
item = platformLicenseItem(),
|
||||||
|
isTablet = isTablet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlainSettingsStack(
|
||||||
|
title: String,
|
||||||
|
isTablet: Boolean,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(if (isTablet) 12.dp else 10.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttributionRow(
|
||||||
|
item: AttributionItem,
|
||||||
|
isTablet: Boolean,
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val title = stringResource(item.titleRes)
|
||||||
|
LinkedPlainRow(
|
||||||
|
title = title,
|
||||||
|
body = stringResource(item.bodyRes),
|
||||||
|
link = item.link,
|
||||||
|
isTablet = isTablet,
|
||||||
|
leading = item.logo?.let { logo ->
|
||||||
|
{
|
||||||
|
IntegrationLogoImage(
|
||||||
|
painter = integrationLogoPainter(logo),
|
||||||
|
contentDescription = title,
|
||||||
|
isTablet = isTablet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpen = { uriHandler.openUri(item.link) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LicenseRow(
|
||||||
|
item: LicenseItem,
|
||||||
|
isTablet: Boolean,
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val itemBody = stringResource(item.bodyRes)
|
||||||
|
val itemLicense = stringResource(item.licenseRes)
|
||||||
|
val body = buildString {
|
||||||
|
append(itemBody)
|
||||||
|
append("\n")
|
||||||
|
append(itemLicense)
|
||||||
|
}
|
||||||
|
LinkedPlainRow(
|
||||||
|
title = stringResource(item.titleRes),
|
||||||
|
body = body,
|
||||||
|
link = item.link,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onOpen = { uriHandler.openUri(item.link) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LinkedPlainRow(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
link: String,
|
||||||
|
isTablet: Boolean,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
) {
|
||||||
|
val verticalPadding = if (isTablet) 18.dp else 16.dp
|
||||||
|
val horizontalPadding = if (isTablet) 4.dp else 0.dp
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onOpen)
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(if (isTablet) 18.dp else 14.dp),
|
||||||
|
) {
|
||||||
|
leading?.invoke()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = link,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
.size(if (isTablet) 22.dp else 20.dp)
|
||||||
|
.alpha(0.72f),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IntegrationLogoImage(
|
||||||
|
painter: Painter,
|
||||||
|
contentDescription: String,
|
||||||
|
isTablet: Boolean,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painter,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
.size(if (isTablet) 46.dp else 40.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlainStackDivider() {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.18f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attributionItems(): List<AttributionItem> = listOf(
|
||||||
|
AttributionItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_tmdb_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_tmdb_body,
|
||||||
|
logo = IntegrationLogo.Tmdb,
|
||||||
|
link = TmdbUrl,
|
||||||
|
),
|
||||||
|
AttributionItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_trakt_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_trakt_body,
|
||||||
|
logo = IntegrationLogo.Trakt,
|
||||||
|
link = TraktUrl,
|
||||||
|
),
|
||||||
|
AttributionItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_mdblist_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_mdblist_body,
|
||||||
|
logo = IntegrationLogo.MdbList,
|
||||||
|
link = MdbListUrl,
|
||||||
|
),
|
||||||
|
AttributionItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_introdb_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_introdb_body,
|
||||||
|
logo = IntegrationLogo.IntroDb,
|
||||||
|
link = IntroDbUrl,
|
||||||
|
),
|
||||||
|
AttributionItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_imdb_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_imdb_body,
|
||||||
|
logo = null,
|
||||||
|
link = ImdbDatasetsUrl,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun appLicenseItem(): LicenseItem =
|
||||||
|
LicenseItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_nuvio_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_nuvio_body,
|
||||||
|
licenseRes = Res.string.settings_licenses_attributions_nuvio_license,
|
||||||
|
link = NuvioRepositoryUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun platformLicenseItem(): LicenseItem =
|
||||||
|
if (isIos) {
|
||||||
|
LicenseItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_mpvkit_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_mpvkit_body,
|
||||||
|
licenseRes = Res.string.settings_licenses_attributions_mpvkit_license,
|
||||||
|
link = MpvKitUrl,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LicenseItem(
|
||||||
|
titleRes = Res.string.settings_licenses_attributions_exoplayer_title,
|
||||||
|
bodyRes = Res.string.settings_licenses_attributions_exoplayer_body,
|
||||||
|
licenseRes = Res.string.settings_licenses_attributions_exoplayer_license,
|
||||||
|
link = ApacheLicenseUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,7 @@ fun HomescreenSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
heroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||||
items = homescreenSettingsUiState.items,
|
items = homescreenSettingsUiState.items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +136,7 @@ fun ContinueWatchingSettingsScreen(
|
||||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
|
sortMode = continueWatchingPreferencesUiState.sortMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_content_discov
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen
|
import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
|
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
|
||||||
|
|
@ -58,6 +59,11 @@ internal enum class SettingsPage(
|
||||||
category = SettingsCategory.About,
|
category = SettingsCategory.About,
|
||||||
parentPage = Root,
|
parentPage = Root,
|
||||||
),
|
),
|
||||||
|
LicensesAttributions(
|
||||||
|
titleRes = Res.string.compose_settings_page_licenses_attributions,
|
||||||
|
category = SettingsCategory.About,
|
||||||
|
parentPage = Root,
|
||||||
|
),
|
||||||
Playback(
|
Playback(
|
||||||
titleRes = Res.string.compose_settings_page_playback,
|
titleRes = Res.string.compose_settings_page_playback,
|
||||||
category = SettingsCategory.General,
|
category = SettingsCategory.General,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import nuvio.composeapp.generated.resources.compose_about_version_format
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
|
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
|
||||||
|
import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
|
import nuvio.composeapp.generated.resources.compose_settings_page_notifications
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_playback
|
import nuvio.composeapp.generated.resources.compose_settings_page_playback
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors
|
import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors
|
||||||
|
|
@ -48,6 +49,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_content_discov
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
|
import nuvio.composeapp.generated.resources.compose_settings_page_trakt
|
||||||
import nuvio.composeapp.generated.resources.settings_playback_subtitle
|
import nuvio.composeapp.generated.resources.settings_playback_subtitle
|
||||||
import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle
|
import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle
|
||||||
|
import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.settingsRootContent(
|
internal fun LazyListScope.settingsRootContent(
|
||||||
|
|
@ -59,6 +61,7 @@ internal fun LazyListScope.settingsRootContent(
|
||||||
onIntegrationsClick: () -> Unit,
|
onIntegrationsClick: () -> Unit,
|
||||||
onTraktClick: () -> Unit,
|
onTraktClick: () -> Unit,
|
||||||
onSupportersContributorsClick: () -> Unit,
|
onSupportersContributorsClick: () -> Unit,
|
||||||
|
onLicensesAttributionsClick: () -> Unit,
|
||||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onDownloadsClick: () -> Unit,
|
onDownloadsClick: () -> Unit,
|
||||||
onAccountClick: () -> Unit,
|
onAccountClick: () -> Unit,
|
||||||
|
|
@ -175,6 +178,14 @@ internal fun LazyListScope.settingsRootContent(
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
onClick = onSupportersContributorsClick,
|
onClick = onSupportersContributorsClick,
|
||||||
)
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = stringResource(Res.string.compose_settings_page_licenses_attributions),
|
||||||
|
description = stringResource(Res.string.about_licenses_attributions_subtitle),
|
||||||
|
icon = Icons.Rounded.Info,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = onLicensesAttributionsClick,
|
||||||
|
)
|
||||||
if (onCheckForUpdatesClick != null) {
|
if (onCheckForUpdatesClick != null) {
|
||||||
SettingsGroupDivider(isTablet = isTablet)
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.max
|
import androidx.compose.ui.unit.max
|
||||||
|
|
@ -48,6 +58,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||||
import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
import com.nuvio.app.features.details.MetaScreenSettingsUiState
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||||
import com.nuvio.app.core.ui.PosterCardStyleUiState
|
import com.nuvio.app.core.ui.PosterCardStyleUiState
|
||||||
|
import com.nuvio.app.features.collection.CollectionRepository
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
import com.nuvio.app.features.home.HomeCatalogSettingsItem
|
||||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.mdblist.MdbListSettings
|
import com.nuvio.app.features.mdblist.MdbListSettings
|
||||||
|
|
@ -66,8 +77,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.compose_settings_page_root
|
import nuvio.composeapp.generated.resources.compose_settings_page_root
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
private val SettingsSearchRevealThreshold = 28.dp
|
||||||
|
private const val SettingsSearchRevealAnimationMillis = 240L
|
||||||
|
private const val SettingsSearchRevealHapticDelayMillis = 90L
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -80,6 +97,7 @@ fun SettingsScreen(
|
||||||
onDownloadsClick: () -> Unit = {},
|
onDownloadsClick: () -> Unit = {},
|
||||||
onAccountClick: () -> Unit = {},
|
onAccountClick: () -> Unit = {},
|
||||||
onSupportersContributorsClick: () -> Unit = {},
|
onSupportersContributorsClick: () -> Unit = {},
|
||||||
|
onLicensesAttributionsClick: () -> Unit = {},
|
||||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsClick: () -> Unit = {},
|
onCollectionsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -144,6 +162,7 @@ fun SettingsScreen(
|
||||||
HomeCatalogSettingsRepository.snapshot()
|
HomeCatalogSettingsRepository.snapshot()
|
||||||
HomeCatalogSettingsRepository.uiState
|
HomeCatalogSettingsRepository.uiState
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||||
val metaScreenSettingsUiState by remember {
|
val metaScreenSettingsUiState by remember {
|
||||||
MetaScreenSettingsRepository.ensureLoaded()
|
MetaScreenSettingsRepository.ensureLoaded()
|
||||||
MetaScreenSettingsRepository.uiState
|
MetaScreenSettingsRepository.uiState
|
||||||
|
|
@ -166,6 +185,14 @@ fun SettingsScreen(
|
||||||
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
|
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
CollectionRepository.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(collections) {
|
||||||
|
HomeCatalogSettingsRepository.syncCollections(collections)
|
||||||
|
}
|
||||||
|
|
||||||
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
|
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
|
||||||
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
||||||
val previousPage = page.previousPage()
|
val previousPage = page.previousPage()
|
||||||
|
|
@ -210,6 +237,7 @@ fun SettingsScreen(
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
|
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||||
homescreenItems = homescreenSettingsUiState.items,
|
homescreenItems = homescreenSettingsUiState.items,
|
||||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||||
|
|
@ -217,6 +245,7 @@ fun SettingsScreen(
|
||||||
onSwitchProfile = onSwitchProfile,
|
onSwitchProfile = onSwitchProfile,
|
||||||
onDownloadsClick = onDownloadsClick,
|
onDownloadsClick = onDownloadsClick,
|
||||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||||
|
onLicensesAttributionsClick = onLicensesAttributionsClick,
|
||||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onCollectionsClick = onCollectionsClick,
|
onCollectionsClick = onCollectionsClick,
|
||||||
)
|
)
|
||||||
|
|
@ -255,6 +284,7 @@ fun SettingsScreen(
|
||||||
traktSettingsUiState = traktSettingsUiState,
|
traktSettingsUiState = traktSettingsUiState,
|
||||||
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
|
||||||
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
|
||||||
|
homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
|
||||||
homescreenItems = homescreenSettingsUiState.items,
|
homescreenItems = homescreenSettingsUiState.items,
|
||||||
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
metaScreenSettingsUiState = metaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
|
||||||
|
|
@ -268,6 +298,7 @@ fun SettingsScreen(
|
||||||
onDownloadsClick = onDownloadsClick,
|
onDownloadsClick = onDownloadsClick,
|
||||||
onAccountClick = onAccountClick,
|
onAccountClick = onAccountClick,
|
||||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||||
|
onLicensesAttributionsClick = onLicensesAttributionsClick,
|
||||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
onCollectionsClick = onCollectionsClick,
|
onCollectionsClick = onCollectionsClick,
|
||||||
)
|
)
|
||||||
|
|
@ -310,6 +341,7 @@ private fun MobileSettingsScreen(
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
homescreenHeroEnabled: Boolean,
|
homescreenHeroEnabled: Boolean,
|
||||||
homescreenHideUnreleasedContent: Boolean,
|
homescreenHideUnreleasedContent: Boolean,
|
||||||
|
homescreenHideCatalogUnderline: Boolean,
|
||||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||||
|
|
@ -323,12 +355,73 @@ private fun MobileSettingsScreen(
|
||||||
onDownloadsClick: () -> Unit = {},
|
onDownloadsClick: () -> Unit = {},
|
||||||
onAccountClick: () -> Unit = {},
|
onAccountClick: () -> Unit = {},
|
||||||
onSupportersContributorsClick: () -> Unit = {},
|
onSupportersContributorsClick: () -> Unit = {},
|
||||||
|
onLicensesAttributionsClick: () -> Unit = {},
|
||||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsClick: () -> Unit = {},
|
onCollectionsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val saveableStateHolder = rememberSaveableStateHolder()
|
val saveableStateHolder = rememberSaveableStateHolder()
|
||||||
saveableStateHolder.SaveableStateProvider(page.name) {
|
saveableStateHolder.SaveableStateProvider(page.name) {
|
||||||
NuvioScreen {
|
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
val hapticScope = rememberCoroutineScope()
|
||||||
|
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
|
||||||
|
page = page,
|
||||||
|
listState = listState,
|
||||||
|
query = settingsSearchQuery,
|
||||||
|
searchVisible = rootSearchVisible,
|
||||||
|
) {
|
||||||
|
rootSearchVisible = true
|
||||||
|
rootSearchRevealAnimating = true
|
||||||
|
hapticScope.launch {
|
||||||
|
delay(SettingsSearchRevealHapticDelayMillis)
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val searchEntries = settingsSearchEntries(
|
||||||
|
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
switchProfileAvailable = onSwitchProfile != null,
|
||||||
|
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun openSearchTarget(target: SettingsSearchTarget) {
|
||||||
|
when (target) {
|
||||||
|
is SettingsSearchTarget.Page -> when (target.page) {
|
||||||
|
SettingsPage.Account -> onAccountClick()
|
||||||
|
SettingsPage.SupportersContributors -> onSupportersContributorsClick()
|
||||||
|
SettingsPage.LicensesAttributions -> onLicensesAttributionsClick()
|
||||||
|
SettingsPage.ContinueWatching -> onContinueWatchingClick()
|
||||||
|
SettingsPage.Addons -> onAddonsClick()
|
||||||
|
SettingsPage.Plugins -> {
|
||||||
|
if (AppFeaturePolicy.pluginsEnabled) {
|
||||||
|
onPluginsClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Homescreen -> onHomescreenClick()
|
||||||
|
SettingsPage.MetaScreen -> onMetaScreenClick()
|
||||||
|
else -> onPageChange(target.page)
|
||||||
|
}
|
||||||
|
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||||
|
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||||
|
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||||
|
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(rootSearchRevealAnimating) {
|
||||||
|
if (rootSearchRevealAnimating) {
|
||||||
|
delay(SettingsSearchRevealAnimationMillis)
|
||||||
|
rootSearchRevealAnimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NuvioScreen(
|
||||||
|
modifier = Modifier.nestedScroll(rootSearchRevealConnection),
|
||||||
|
listState = listState,
|
||||||
|
) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
val previousPage = page.previousPage()
|
val previousPage = page.previousPage()
|
||||||
NuvioScreenHeader(
|
NuvioScreenHeader(
|
||||||
|
|
@ -338,26 +431,43 @@ private fun MobileSettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when (page) {
|
when (page) {
|
||||||
SettingsPage.Root -> settingsRootContent(
|
SettingsPage.Root -> {
|
||||||
isTablet = false,
|
settingsSearchRootContent(
|
||||||
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
|
query = settingsSearchQuery,
|
||||||
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
|
entries = searchEntries,
|
||||||
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
|
isTablet = false,
|
||||||
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
|
showSearchField = rootSearchVisible,
|
||||||
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
|
animateSearchField = rootSearchRevealAnimating,
|
||||||
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
|
onQueryChange = { settingsSearchQuery = it },
|
||||||
onSupportersContributorsClick = onSupportersContributorsClick,
|
onTargetClick = { openSearchTarget(it) },
|
||||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
)
|
||||||
onDownloadsClick = onDownloadsClick,
|
if (settingsSearchQuery.isBlank()) {
|
||||||
onAccountClick = onAccountClick,
|
settingsRootContent(
|
||||||
onSwitchProfileClick = onSwitchProfile,
|
isTablet = false,
|
||||||
)
|
onPlaybackClick = { onPageChange(SettingsPage.Playback) },
|
||||||
|
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
|
||||||
|
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
|
||||||
|
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
|
||||||
|
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
|
||||||
|
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
|
||||||
|
onSupportersContributorsClick = onSupportersContributorsClick,
|
||||||
|
onLicensesAttributionsClick = onLicensesAttributionsClick,
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
|
onDownloadsClick = onDownloadsClick,
|
||||||
|
onAccountClick = onAccountClick,
|
||||||
|
onSwitchProfileClick = onSwitchProfile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsPage.Account -> accountSettingsContent(
|
SettingsPage.Account -> accountSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
)
|
)
|
||||||
SettingsPage.SupportersContributors -> supportersContributorsContent(
|
SettingsPage.SupportersContributors -> supportersContributorsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
)
|
)
|
||||||
|
SettingsPage.LicensesAttributions -> licensesAttributionsContent(
|
||||||
|
isTablet = false,
|
||||||
|
)
|
||||||
SettingsPage.Playback -> playbackSettingsContent(
|
SettingsPage.Playback -> playbackSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
showLoadingOverlay = showLoadingOverlay,
|
showLoadingOverlay = showLoadingOverlay,
|
||||||
|
|
@ -402,6 +512,7 @@ private fun MobileSettingsScreen(
|
||||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
|
sortMode = continueWatchingPreferencesUiState.sortMode,
|
||||||
)
|
)
|
||||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
|
|
@ -422,6 +533,7 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
heroEnabled = homescreenHeroEnabled,
|
heroEnabled = homescreenHeroEnabled,
|
||||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = homescreenHideCatalogUnderline,
|
||||||
items = homescreenItems,
|
items = homescreenItems,
|
||||||
)
|
)
|
||||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||||
|
|
@ -453,6 +565,48 @@ private fun MobileSettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberSettingsRootSearchRevealConnection(
|
||||||
|
page: SettingsPage,
|
||||||
|
listState: LazyListState,
|
||||||
|
query: String,
|
||||||
|
searchVisible: Boolean,
|
||||||
|
onReveal: () -> Unit,
|
||||||
|
): NestedScrollConnection {
|
||||||
|
val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() }
|
||||||
|
val currentOnReveal by rememberUpdatedState(onReveal)
|
||||||
|
var pullDistancePx by remember(page) { mutableStateOf(0f) }
|
||||||
|
var revealTriggered by remember(page) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
return remember(page, listState, query, searchVisible, revealThresholdPx) {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource,
|
||||||
|
): Offset {
|
||||||
|
val isRootAtTop = page == SettingsPage.Root &&
|
||||||
|
listState.firstVisibleItemIndex == 0 &&
|
||||||
|
listState.firstVisibleItemScrollOffset == 0
|
||||||
|
val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank()
|
||||||
|
|
||||||
|
if (canRevealSearch && available.y > 0f) {
|
||||||
|
pullDistancePx += available.y
|
||||||
|
if (pullDistancePx >= revealThresholdPx) {
|
||||||
|
pullDistancePx = 0f
|
||||||
|
revealTriggered = true
|
||||||
|
currentOnReveal()
|
||||||
|
}
|
||||||
|
} else if (!isRootAtTop || available.y < 0f) {
|
||||||
|
pullDistancePx = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
return Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TabletSettingsScreen(
|
private fun TabletSettingsScreen(
|
||||||
page: SettingsPage,
|
page: SettingsPage,
|
||||||
|
|
@ -488,6 +642,7 @@ private fun TabletSettingsScreen(
|
||||||
traktSettingsUiState: TraktSettingsUiState,
|
traktSettingsUiState: TraktSettingsUiState,
|
||||||
homescreenHeroEnabled: Boolean,
|
homescreenHeroEnabled: Boolean,
|
||||||
homescreenHideUnreleasedContent: Boolean,
|
homescreenHideUnreleasedContent: Boolean,
|
||||||
|
homescreenHideCatalogUnderline: Boolean,
|
||||||
homescreenItems: List<HomeCatalogSettingsItem>,
|
homescreenItems: List<HomeCatalogSettingsItem>,
|
||||||
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
metaScreenSettingsUiState: MetaScreenSettingsUiState,
|
||||||
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
|
||||||
|
|
@ -495,6 +650,7 @@ private fun TabletSettingsScreen(
|
||||||
onSwitchProfile: (() -> Unit)? = null,
|
onSwitchProfile: (() -> Unit)? = null,
|
||||||
onDownloadsClick: () -> Unit = {},
|
onDownloadsClick: () -> Unit = {},
|
||||||
onSupportersContributorsClick: () -> Unit = {},
|
onSupportersContributorsClick: () -> Unit = {},
|
||||||
|
onLicensesAttributionsClick: () -> Unit = {},
|
||||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||||
onCollectionsClick: () -> Unit = {},
|
onCollectionsClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -559,11 +715,54 @@ private fun TabletSettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
saveableStateHolder.SaveableStateProvider(page.name) {
|
saveableStateHolder.SaveableStateProvider(page.name) {
|
||||||
|
var settingsSearchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var rootSearchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
val hapticScope = rememberCoroutineScope()
|
||||||
|
val searchEntries = settingsSearchEntries(
|
||||||
|
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||||
|
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||||
|
switchProfileAvailable = onSwitchProfile != null,
|
||||||
|
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun openSearchTarget(target: SettingsSearchTarget) {
|
||||||
|
when (target) {
|
||||||
|
is SettingsSearchTarget.Page -> openInlinePage(target.page)
|
||||||
|
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||||
|
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||||
|
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||||
|
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
|
val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current
|
||||||
|
val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection(
|
||||||
|
page = page,
|
||||||
|
listState = listState,
|
||||||
|
query = settingsSearchQuery,
|
||||||
|
searchVisible = rootSearchVisible,
|
||||||
|
) {
|
||||||
|
rootSearchVisible = true
|
||||||
|
rootSearchRevealAnimating = true
|
||||||
|
hapticScope.launch {
|
||||||
|
delay(SettingsSearchRevealHapticDelayMillis)
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(rootSearchRevealAnimating) {
|
||||||
|
if (rootSearchRevealAnimating) {
|
||||||
|
delay(SettingsSearchRevealAnimationMillis)
|
||||||
|
rootSearchRevealAnimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(rootSearchRevealConnection),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 40.dp,
|
start = 40.dp,
|
||||||
top = topOffset,
|
top = topOffset,
|
||||||
|
|
@ -576,7 +775,11 @@ private fun TabletSettingsScreen(
|
||||||
val previousPage = page.previousPage()
|
val previousPage = page.previousPage()
|
||||||
TabletPageHeader(
|
TabletPageHeader(
|
||||||
title = if (page == SettingsPage.Root) {
|
title = if (page == SettingsPage.Root) {
|
||||||
stringResource(activeCategory.labelRes)
|
if (settingsSearchQuery.isBlank()) {
|
||||||
|
stringResource(activeCategory.labelRes)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.compose_settings_page_root)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
stringResource(page.titleRes)
|
stringResource(page.titleRes)
|
||||||
},
|
},
|
||||||
|
|
@ -585,29 +788,46 @@ private fun TabletSettingsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (page) {
|
when (page) {
|
||||||
SettingsPage.Root -> settingsRootContent(
|
SettingsPage.Root -> {
|
||||||
isTablet = true,
|
settingsSearchRootContent(
|
||||||
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
|
query = settingsSearchQuery,
|
||||||
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
|
entries = searchEntries,
|
||||||
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
|
isTablet = true,
|
||||||
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
|
showSearchField = rootSearchVisible,
|
||||||
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
|
animateSearchField = rootSearchRevealAnimating,
|
||||||
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
|
onQueryChange = { settingsSearchQuery = it },
|
||||||
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
|
onTargetClick = { openSearchTarget(it) },
|
||||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
)
|
||||||
onDownloadsClick = onDownloadsClick,
|
if (settingsSearchQuery.isBlank()) {
|
||||||
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
settingsRootContent(
|
||||||
onSwitchProfileClick = onSwitchProfile,
|
isTablet = true,
|
||||||
showAccountSection = activeCategory == SettingsCategory.Account,
|
onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
|
||||||
showGeneralSection = activeCategory == SettingsCategory.General,
|
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
|
||||||
showAboutSection = activeCategory == SettingsCategory.About,
|
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
|
||||||
)
|
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
|
||||||
|
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
|
||||||
|
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
|
||||||
|
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
|
||||||
|
onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) },
|
||||||
|
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||||
|
onDownloadsClick = onDownloadsClick,
|
||||||
|
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
||||||
|
onSwitchProfileClick = onSwitchProfile,
|
||||||
|
showAccountSection = activeCategory == SettingsCategory.Account,
|
||||||
|
showGeneralSection = activeCategory == SettingsCategory.General,
|
||||||
|
showAboutSection = activeCategory == SettingsCategory.About,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
SettingsPage.Account -> accountSettingsContent(
|
SettingsPage.Account -> accountSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
)
|
)
|
||||||
SettingsPage.SupportersContributors -> supportersContributorsContent(
|
SettingsPage.SupportersContributors -> supportersContributorsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
)
|
)
|
||||||
|
SettingsPage.LicensesAttributions -> licensesAttributionsContent(
|
||||||
|
isTablet = true,
|
||||||
|
)
|
||||||
SettingsPage.Playback -> playbackSettingsContent(
|
SettingsPage.Playback -> playbackSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
showLoadingOverlay = showLoadingOverlay,
|
showLoadingOverlay = showLoadingOverlay,
|
||||||
|
|
@ -652,6 +872,7 @@ private fun TabletSettingsScreen(
|
||||||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||||
|
sortMode = continueWatchingPreferencesUiState.sortMode,
|
||||||
)
|
)
|
||||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
|
|
@ -672,6 +893,7 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
heroEnabled = homescreenHeroEnabled,
|
heroEnabled = homescreenHeroEnabled,
|
||||||
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
hideUnreleasedContent = homescreenHideUnreleasedContent,
|
||||||
|
hideCatalogUnderline = homescreenHideCatalogUnderline,
|
||||||
items = homescreenItems,
|
items = homescreenItems,
|
||||||
)
|
)
|
||||||
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
SettingsPage.MetaScreen -> metaScreenSettingsContent(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -22,8 +22,20 @@ internal expect fun epochMs(): Long
|
||||||
object StreamLinkCacheRepository {
|
object StreamLinkCacheRepository {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun contentKey(type: String, videoId: String): String =
|
fun contentKey(
|
||||||
"${type.lowercase()}|$videoId"
|
type: String,
|
||||||
|
videoId: String,
|
||||||
|
parentMetaId: String? = null,
|
||||||
|
season: Int? = null,
|
||||||
|
episode: Int? = null,
|
||||||
|
): String {
|
||||||
|
val normalizedType = type.lowercase()
|
||||||
|
return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) {
|
||||||
|
"$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId"
|
||||||
|
} else {
|
||||||
|
"$normalizedType|$videoId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun save(
|
fun save(
|
||||||
contentKey: String,
|
contentKey: String,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class StreamsUiState(
|
data class StreamsUiState(
|
||||||
|
val requestToken: String? = null,
|
||||||
val groups: List<AddonStreamGroup> = emptyList(),
|
val groups: List<AddonStreamGroup> = emptyList(),
|
||||||
val activeAddonIds: Set<String> = emptySet(),
|
val activeAddonIds: Set<String> = emptySet(),
|
||||||
val selectedFilter: String? = null,
|
val selectedFilter: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,15 @@ object StreamsRepository {
|
||||||
private var activeJob: Job? = null
|
private var activeJob: Job? = null
|
||||||
private var activeRequestKey: String? = null
|
private var activeRequestKey: String? = null
|
||||||
|
|
||||||
|
fun requestToken(
|
||||||
|
type: String,
|
||||||
|
videoId: String,
|
||||||
|
season: Int? = null,
|
||||||
|
episode: Int? = null,
|
||||||
|
manualSelection: Boolean = false,
|
||||||
|
): String =
|
||||||
|
"$type::$videoId::$season::$episode::$manualSelection"
|
||||||
|
|
||||||
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
|
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
|
||||||
load(
|
load(
|
||||||
type = type,
|
type = type,
|
||||||
|
|
@ -65,7 +74,14 @@ object StreamsRepository {
|
||||||
} else {
|
} else {
|
||||||
PluginsUiState(pluginsEnabled = false)
|
PluginsUiState(pluginsEnabled = false)
|
||||||
}
|
}
|
||||||
val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
|
val requestToken = requestToken(
|
||||||
|
type = type,
|
||||||
|
videoId = videoId,
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
manualSelection = manualSelection,
|
||||||
|
)
|
||||||
|
val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
|
||||||
val currentState = _uiState.value
|
val currentState = _uiState.value
|
||||||
if (
|
if (
|
||||||
!forceRefresh &&
|
!forceRefresh &&
|
||||||
|
|
@ -78,7 +94,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
activeRequestKey = requestKey
|
activeRequestKey = requestKey
|
||||||
activeJob?.cancel()
|
activeJob?.cancel()
|
||||||
_uiState.value = StreamsUiState()
|
_uiState.value = StreamsUiState(requestToken = requestToken)
|
||||||
|
|
||||||
PlayerSettingsRepository.ensureLoaded()
|
PlayerSettingsRepository.ensureLoaded()
|
||||||
val playerSettings = PlayerSettingsRepository.uiState.value
|
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||||
|
|
@ -90,6 +106,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
if (isDirectAutoPlayFlow) {
|
if (isDirectAutoPlayFlow) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
isDirectAutoPlayFlow = true,
|
isDirectAutoPlayFlow = true,
|
||||||
showDirectAutoPlayOverlay = true,
|
showDirectAutoPlayOverlay = true,
|
||||||
)
|
)
|
||||||
|
|
@ -105,6 +122,7 @@ object StreamsRepository {
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
groups = listOf(group),
|
groups = listOf(group),
|
||||||
activeAddonIds = setOf("embedded"),
|
activeAddonIds = setOf("embedded"),
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
|
|
@ -125,6 +143,7 @@ object StreamsRepository {
|
||||||
|
|
||||||
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
|
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
|
||||||
)
|
)
|
||||||
|
|
@ -151,8 +170,9 @@ object StreamsRepository {
|
||||||
|
|
||||||
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
|
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
|
||||||
|
|
||||||
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
isAnyLoading = false,
|
isAnyLoading = false,
|
||||||
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
|
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
|
||||||
)
|
)
|
||||||
|
|
@ -176,6 +196,7 @@ object StreamsRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.value = StreamsUiState(
|
_uiState.value = StreamsUiState(
|
||||||
|
requestToken = requestToken,
|
||||||
groups = initialGroups,
|
groups = initialGroups,
|
||||||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||||
isAnyLoading = true,
|
isAnyLoading = true,
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ fun StreamsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(type, videoId, manualSelection) {
|
LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
|
||||||
StreamsRepository.load(
|
StreamsRepository.load(
|
||||||
type = type,
|
type = type,
|
||||||
videoId = videoId,
|
videoId = videoId,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ private const val COMMENTS_SORT = "likes"
|
||||||
private const val COMMENTS_LIMIT = 100
|
private const val COMMENTS_LIMIT = 100
|
||||||
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
|
private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L
|
||||||
private val INLINE_SPOILER_REGEX = Regex(
|
private val INLINE_SPOILER_REGEX = Regex(
|
||||||
"\\[spoiler\\].*?\\[/spoiler\\]",
|
"(?is)\\[spoiler\\].*?\\[/spoiler\\]"
|
||||||
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL),
|
|
||||||
)
|
)
|
||||||
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)
|
private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ object TraktEpisodeMappingService {
|
||||||
videoId: String?,
|
videoId: String?,
|
||||||
season: Int?,
|
season: Int?,
|
||||||
episode: Int?,
|
episode: Int?,
|
||||||
|
episodeTitle: String? = null,
|
||||||
): EpisodeMappingEntry? {
|
): EpisodeMappingEntry? {
|
||||||
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
|
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
|
|
@ -77,7 +78,7 @@ object TraktEpisodeMappingService {
|
||||||
requestedSeason = requestedSeason,
|
requestedSeason = requestedSeason,
|
||||||
requestedEpisode = requestedEpisode,
|
requestedEpisode = requestedEpisode,
|
||||||
requestedVideoId = videoId,
|
requestedVideoId = videoId,
|
||||||
requestedTitle = null,
|
requestedTitle = episodeTitle,
|
||||||
addonEpisodes = addonEpisodes,
|
addonEpisodes = addonEpisodes,
|
||||||
traktEpisodes = traktEpisodes,
|
traktEpisodes = traktEpisodes,
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
@ -176,18 +177,18 @@ object TraktEpisodeMappingService {
|
||||||
|
|
||||||
// ── Season structure comparison ───────────────────────────────────────
|
// ── Season structure comparison ───────────────────────────────────────
|
||||||
|
|
||||||
private fun hasSameSeasonStructure(
|
internal fun hasSameSeasonStructure(
|
||||||
addonEpisodes: List<EpisodeMappingEntry>,
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
traktEpisodes: List<EpisodeMappingEntry>,
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
|
val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size }
|
||||||
val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
|
val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size }
|
||||||
return addonSeasons == traktSeasons
|
return addonPerSeason == traktPerSeason
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Forward mapping: addon → Trakt ──────────────────────────────────
|
// ── Forward mapping: addon → Trakt ──────────────────────────────────
|
||||||
|
|
||||||
private fun remapEpisodeByTitleOrIndex(
|
internal fun remapEpisodeByTitleOrIndex(
|
||||||
requestedSeason: Int,
|
requestedSeason: Int,
|
||||||
requestedEpisode: Int,
|
requestedEpisode: Int,
|
||||||
requestedVideoId: String?,
|
requestedVideoId: String?,
|
||||||
|
|
@ -195,63 +196,72 @@ object TraktEpisodeMappingService {
|
||||||
addonEpisodes: List<EpisodeMappingEntry>,
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
traktEpisodes: List<EpisodeMappingEntry>,
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
): EpisodeMappingEntry? {
|
): EpisodeMappingEntry? {
|
||||||
// Find the addon episode entry
|
return remapEpisodeBetweenLists(
|
||||||
val addonEntry = addonEpisodes.firstOrNull {
|
requestedSeason = requestedSeason,
|
||||||
it.season == requestedSeason && it.episode == requestedEpisode
|
requestedEpisode = requestedEpisode,
|
||||||
} ?: addonEpisodes.firstOrNull {
|
requestedVideoId = requestedVideoId,
|
||||||
!requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId
|
requestedTitle = requestedTitle,
|
||||||
} ?: return null
|
sourceEpisodes = addonEpisodes,
|
||||||
|
targetEpisodes = traktEpisodes,
|
||||||
// Try title match first
|
)
|
||||||
val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
|
|
||||||
if (!titleToMatch.isNullOrBlank()) {
|
|
||||||
val titleMatch = traktEpisodes.firstOrNull { target ->
|
|
||||||
!target.title.isNullOrBlank() &&
|
|
||||||
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
|
|
||||||
}
|
|
||||||
if (titleMatch != null) {
|
|
||||||
return titleMatch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: global index mapping
|
|
||||||
val addonIndex = addonEpisodes.indexOf(addonEntry)
|
|
||||||
if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null
|
|
||||||
|
|
||||||
return traktEpisodes[addonIndex]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
|
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
|
||||||
|
|
||||||
private fun reverseRemapEpisodeByTitleOrIndex(
|
internal fun reverseRemapEpisodeByTitleOrIndex(
|
||||||
requestedSeason: Int,
|
requestedSeason: Int,
|
||||||
requestedEpisode: Int,
|
requestedEpisode: Int,
|
||||||
requestedTitle: String?,
|
requestedTitle: String?,
|
||||||
addonEpisodes: List<EpisodeMappingEntry>,
|
addonEpisodes: List<EpisodeMappingEntry>,
|
||||||
traktEpisodes: List<EpisodeMappingEntry>,
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
): EpisodeMappingEntry? {
|
): EpisodeMappingEntry? {
|
||||||
// Find the Trakt episode entry
|
return remapEpisodeBetweenLists(
|
||||||
val traktEntry = traktEpisodes.firstOrNull {
|
requestedSeason = requestedSeason,
|
||||||
it.season == requestedSeason && it.episode == requestedEpisode
|
requestedEpisode = requestedEpisode,
|
||||||
} ?: return null
|
requestedVideoId = null,
|
||||||
|
requestedTitle = requestedTitle,
|
||||||
|
sourceEpisodes = traktEpisodes,
|
||||||
|
targetEpisodes = addonEpisodes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Try title match first
|
private fun remapEpisodeBetweenLists(
|
||||||
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
|
requestedSeason: Int,
|
||||||
if (!titleToMatch.isNullOrBlank()) {
|
requestedEpisode: Int,
|
||||||
val titleMatch = addonEpisodes.firstOrNull { target ->
|
requestedVideoId: String?,
|
||||||
!target.title.isNullOrBlank() &&
|
requestedTitle: String?,
|
||||||
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
|
sourceEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
targetEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null
|
||||||
|
|
||||||
|
val orderedSourceEpisodes = sourceEpisodes
|
||||||
|
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
|
||||||
|
val orderedTargetEpisodes = targetEpisodes
|
||||||
|
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
|
||||||
|
|
||||||
|
val currentSourceEpisode = requestedVideoId
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { videoId -> orderedSourceEpisodes.firstOrNull { it.videoId == videoId } }
|
||||||
|
?: orderedSourceEpisodes.firstOrNull {
|
||||||
|
it.season == requestedSeason && it.episode == requestedEpisode
|
||||||
}
|
}
|
||||||
if (titleMatch != null) {
|
?: return null
|
||||||
return titleMatch
|
|
||||||
|
val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title)
|
||||||
|
if (isUsefulEpisodeTitle(normalizedTitle)) {
|
||||||
|
val titleMatches = orderedTargetEpisodes.filter {
|
||||||
|
normalizeEpisodeTitle(it.title) == normalizedTitle
|
||||||
|
}
|
||||||
|
if (titleMatches.size == 1) {
|
||||||
|
return titleMatches.first()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: global index mapping
|
val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode)
|
||||||
val traktIndex = traktEpisodes.indexOf(traktEntry)
|
if (sourceIndex !in orderedTargetEpisodes.indices) return null
|
||||||
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
|
|
||||||
|
|
||||||
return addonEpisodes[traktIndex]
|
return orderedTargetEpisodes[sourceIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Addon episodes fetching (with dedup) ───────────────────────────
|
// ── Addon episodes fetching (with dedup) ───────────────────────────
|
||||||
|
|
@ -396,7 +406,7 @@ object TraktEpisodeMappingService {
|
||||||
return when {
|
return when {
|
||||||
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
|
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
|
||||||
contentIds.trakt != null -> contentIds.trakt.toString()
|
contentIds.trakt != null -> contentIds.trakt.toString()
|
||||||
contentIds.tmdb != null -> contentIds.tmdb.toString()
|
!contentIds.slug.isNullOrBlank() -> contentIds.slug
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -405,13 +415,13 @@ object TraktEpisodeMappingService {
|
||||||
return when {
|
return when {
|
||||||
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
|
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
|
||||||
videoIds.trakt != null -> videoIds.trakt.toString()
|
videoIds.trakt != null -> videoIds.trakt.toString()
|
||||||
videoIds.tmdb != null -> videoIds.tmdb.toString()
|
!videoIds.slug.isNullOrBlank() -> videoIds.slug
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TraktExternalIds.hasAnyId(): Boolean =
|
private fun TraktExternalIds.hasAnyId(): Boolean =
|
||||||
!imdb.isNullOrBlank() || trakt != null || tmdb != null
|
!imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank()
|
||||||
|
|
||||||
private fun cacheKey(
|
private fun cacheKey(
|
||||||
contentId: String?,
|
contentId: String?,
|
||||||
|
|
@ -461,9 +471,22 @@ object TraktEpisodeMappingService {
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeTitle(title: String?): String =
|
private fun normalizeEpisodeTitle(title: String?): String {
|
||||||
title.orEmpty().trim().lowercase()
|
return title
|
||||||
.replace(Regex("[^a-z0-9]"), "")
|
.orEmpty()
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9]+"), " ")
|
||||||
|
.trim()
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean {
|
||||||
|
if (normalizedTitle.isBlank()) return false
|
||||||
|
if (normalizedTitle.matches(Regex("episode \\d+"))) return false
|
||||||
|
if (normalizedTitle.matches(Regex("ep \\d+"))) return false
|
||||||
|
if (normalizedTitle.matches(Regex("e \\d+"))) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data classes ────────────────────────────────────────────────────────
|
// ── Data classes ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
|
private val TraktIsoDateTimeRegex = Regex(
|
||||||
|
"""^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? {
|
||||||
|
val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null
|
||||||
|
val year = match.groupValues[1].toIntOrNull() ?: return null
|
||||||
|
val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
|
||||||
|
val day = match.groupValues[3].toIntOrNull() ?: return null
|
||||||
|
val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null
|
||||||
|
val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
|
||||||
|
val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
|
||||||
|
if (day !in 1..daysInMonth(year, month)) return null
|
||||||
|
|
||||||
|
val millisecond = match.groupValues[7]
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.padEnd(3, '0')
|
||||||
|
?.take(3)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null
|
||||||
|
|
||||||
|
return isoEpochDay(year, month, day) * MillisPerDay +
|
||||||
|
hour * MillisPerHour +
|
||||||
|
minute * MillisPerMinute +
|
||||||
|
second * MillisPerSecond +
|
||||||
|
millisecond -
|
||||||
|
offsetMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOffsetMs(value: String): Long? {
|
||||||
|
if (value == "Z") return 0L
|
||||||
|
val sign = when (value.firstOrNull()) {
|
||||||
|
'+' -> 1L
|
||||||
|
'-' -> -1L
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val digits = value.drop(1).replace(":", "")
|
||||||
|
if (digits.length != 4) return null
|
||||||
|
val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null
|
||||||
|
val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null
|
||||||
|
return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isoEpochDay(year: Int, month: Int, day: Int): Long {
|
||||||
|
val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L
|
||||||
|
val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
|
||||||
|
val yearOfEra = adjustedYear - era * 400L
|
||||||
|
val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L
|
||||||
|
val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
|
||||||
|
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
|
||||||
|
return era * 146_097L + dayOfEra - 719_468L
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
private const val MillisPerSecond = 1_000L
|
||||||
|
private const val MillisPerMinute = 60L * MillisPerSecond
|
||||||
|
private const val MillisPerHour = 60L * MillisPerMinute
|
||||||
|
private const val MillisPerDay = 24L * MillisPerHour
|
||||||
|
|
@ -475,27 +475,27 @@ object TraktProgressRepository {
|
||||||
var resolvedEpisode = entry.episodeNumber
|
var resolvedEpisode = entry.episodeNumber
|
||||||
|
|
||||||
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
|
val episode = if (resolvedSeason != null && resolvedEpisode != null) {
|
||||||
// Try direct match first
|
|
||||||
val directMatch = meta.videos.firstOrNull { video ->
|
val directMatch = meta.videos.firstOrNull { video ->
|
||||||
video.season == resolvedSeason && video.episode == resolvedEpisode
|
video.season == resolvedSeason && video.episode == resolvedEpisode
|
||||||
}
|
}
|
||||||
if (directMatch != null) {
|
if (directMatch != null) {
|
||||||
directMatch
|
directMatch
|
||||||
} else {
|
} else {
|
||||||
// Fallback: reverse-remap from Trakt numbering to addon numbering
|
val remapped = resolveAddonEpisodeProgress(
|
||||||
val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
|
contentId = entry.parentMetaId,
|
||||||
if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
|
season = resolvedSeason,
|
||||||
val sorted = meta.videos
|
episode = resolvedEpisode,
|
||||||
.filter { it.season != null && it.episode != null }
|
episodeTitle = entry.episodeTitle,
|
||||||
.sortedWith(compareBy({ it.season }, { it.episode }))
|
)
|
||||||
val globalIndex = resolvedEpisode!! - 1
|
if (remapped != null) {
|
||||||
if (globalIndex in sorted.indices) {
|
resolvedSeason = remapped.season
|
||||||
val remapped = sorted[globalIndex]
|
resolvedEpisode = remapped.episode
|
||||||
resolvedSeason = remapped.season
|
meta.videos.firstOrNull { video ->
|
||||||
resolvedEpisode = remapped.episode
|
video.season == remapped.season && video.episode == remapped.episode
|
||||||
remapped
|
}
|
||||||
} else null
|
} else {
|
||||||
} else null
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -540,7 +540,7 @@ object TraktProgressRepository {
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
|
private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
|
||||||
val show = item.show ?: return null
|
val show = item.show ?: return null
|
||||||
val episode = item.episode ?: return null
|
val episode = item.episode ?: return null
|
||||||
val season = episode.season ?: return null
|
val season = episode.season ?: return null
|
||||||
|
|
@ -551,6 +551,14 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
|
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
|
||||||
if (progressPercent <= 0f) return null
|
if (progressPercent <= 0f) return null
|
||||||
|
val resolvedEpisode = resolveAddonEpisodeProgress(
|
||||||
|
contentId = parentMetaId,
|
||||||
|
season = season,
|
||||||
|
episode = number,
|
||||||
|
episodeTitle = episode.title,
|
||||||
|
)
|
||||||
|
val resolvedSeason = resolvedEpisode?.season ?: season
|
||||||
|
val resolvedNumber = resolvedEpisode?.episode ?: number
|
||||||
|
|
||||||
return WatchProgressEntry(
|
return WatchProgressEntry(
|
||||||
contentType = "series",
|
contentType = "series",
|
||||||
|
|
@ -558,14 +566,14 @@ object TraktProgressRepository {
|
||||||
parentMetaType = "series",
|
parentMetaType = "series",
|
||||||
videoId = buildPlaybackVideoId(
|
videoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
||||||
),
|
),
|
||||||
title = show.title ?: parentMetaId,
|
title = show.title ?: parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
episodeTitle = episode.title,
|
episodeTitle = resolvedEpisode?.title ?: episode.title,
|
||||||
lastPositionMs = 0L,
|
lastPositionMs = 0L,
|
||||||
durationMs = 0L,
|
durationMs = 0L,
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
|
||||||
|
|
@ -575,7 +583,7 @@ object TraktProgressRepository {
|
||||||
).normalizedCompletion()
|
).normalizedCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
|
private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {
|
||||||
val show = item.show ?: return null
|
val show = item.show ?: return null
|
||||||
val episode = item.episode ?: return null
|
val episode = item.episode ?: return null
|
||||||
val season = episode.season ?: return null
|
val season = episode.season ?: return null
|
||||||
|
|
@ -583,6 +591,14 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
|
||||||
if (parentMetaId.isBlank()) return null
|
if (parentMetaId.isBlank()) return null
|
||||||
|
val resolvedEpisode = resolveAddonEpisodeProgress(
|
||||||
|
contentId = parentMetaId,
|
||||||
|
season = season,
|
||||||
|
episode = number,
|
||||||
|
episodeTitle = episode.title,
|
||||||
|
)
|
||||||
|
val resolvedSeason = resolvedEpisode?.season ?: season
|
||||||
|
val resolvedNumber = resolvedEpisode?.episode ?: number
|
||||||
|
|
||||||
return WatchProgressEntry(
|
return WatchProgressEntry(
|
||||||
contentType = "series",
|
contentType = "series",
|
||||||
|
|
@ -590,14 +606,14 @@ object TraktProgressRepository {
|
||||||
parentMetaType = "series",
|
parentMetaType = "series",
|
||||||
videoId = buildPlaybackVideoId(
|
videoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
|
||||||
),
|
),
|
||||||
title = show.title ?: parentMetaId,
|
title = show.title ?: parentMetaId,
|
||||||
seasonNumber = season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = number,
|
episodeNumber = resolvedNumber,
|
||||||
episodeTitle = episode.title,
|
episodeTitle = resolvedEpisode?.title ?: episode.title,
|
||||||
lastPositionMs = 1L,
|
lastPositionMs = 1L,
|
||||||
durationMs = 1L,
|
durationMs = 1L,
|
||||||
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
|
||||||
|
|
@ -627,7 +643,7 @@ object TraktProgressRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapWatchedShowSeed(
|
private suspend fun mapWatchedShowSeed(
|
||||||
item: TraktWatchedShowItem,
|
item: TraktWatchedShowItem,
|
||||||
useFurthestEpisode: Boolean,
|
useFurthestEpisode: Boolean,
|
||||||
): WatchProgressEntry? {
|
): WatchProgressEntry? {
|
||||||
|
|
@ -670,6 +686,14 @@ object TraktProgressRepository {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
val resolvedEpisode = resolveAddonEpisodeProgress(
|
||||||
|
contentId = parentMetaId,
|
||||||
|
season = completedEpisode.season,
|
||||||
|
episode = completedEpisode.episode,
|
||||||
|
episodeTitle = null,
|
||||||
|
)
|
||||||
|
val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season
|
||||||
|
val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode
|
||||||
|
|
||||||
return WatchProgressEntry(
|
return WatchProgressEntry(
|
||||||
contentType = "series",
|
contentType = "series",
|
||||||
|
|
@ -677,13 +701,14 @@ object TraktProgressRepository {
|
||||||
parentMetaType = "series",
|
parentMetaType = "series",
|
||||||
videoId = buildPlaybackVideoId(
|
videoId = buildPlaybackVideoId(
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
seasonNumber = completedEpisode.season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = completedEpisode.episode,
|
episodeNumber = resolvedNumber,
|
||||||
fallbackVideoId = null,
|
fallbackVideoId = null,
|
||||||
),
|
),
|
||||||
title = show.title ?: parentMetaId,
|
title = show.title ?: parentMetaId,
|
||||||
seasonNumber = completedEpisode.season,
|
seasonNumber = resolvedSeason,
|
||||||
episodeNumber = completedEpisode.episode,
|
episodeNumber = resolvedNumber,
|
||||||
|
episodeTitle = resolvedEpisode?.title,
|
||||||
lastPositionMs = 1L,
|
lastPositionMs = 1L,
|
||||||
durationMs = 1L,
|
durationMs = 1L,
|
||||||
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
lastUpdatedEpochMs = completedEpisode.watchedAt,
|
||||||
|
|
@ -710,6 +735,26 @@ object TraktProgressRepository {
|
||||||
?.let { return it }
|
?.let { return it }
|
||||||
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveAddonEpisodeProgress(
|
||||||
|
contentId: String,
|
||||||
|
season: Int,
|
||||||
|
episode: Int,
|
||||||
|
episodeTitle: String?,
|
||||||
|
): EpisodeMappingEntry? {
|
||||||
|
return runCatching {
|
||||||
|
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
|
||||||
|
contentId = contentId,
|
||||||
|
contentType = "series",
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
episodeTitle = episodeTitle,
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,10 @@ internal object TraktScrobbleRepository {
|
||||||
sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
|
sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildItem(
|
suspend fun buildItem(
|
||||||
contentType: String,
|
contentType: String,
|
||||||
parentMetaId: String,
|
parentMetaId: String,
|
||||||
|
videoId: String?,
|
||||||
title: String?,
|
title: String?,
|
||||||
seasonNumber: Int?,
|
seasonNumber: Int?,
|
||||||
episodeNumber: Int?,
|
episodeNumber: Int?,
|
||||||
|
|
@ -81,12 +82,20 @@ internal object TraktScrobbleRepository {
|
||||||
seasonNumber != null &&
|
seasonNumber != null &&
|
||||||
episodeNumber != null
|
episodeNumber != null
|
||||||
) {
|
) {
|
||||||
|
val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
|
||||||
|
contentId = parentMetaId,
|
||||||
|
contentType = contentType,
|
||||||
|
videoId = videoId,
|
||||||
|
season = seasonNumber,
|
||||||
|
episode = episodeNumber,
|
||||||
|
episodeTitle = episodeTitle,
|
||||||
|
)
|
||||||
TraktScrobbleItem.Episode(
|
TraktScrobbleItem.Episode(
|
||||||
showTitle = title,
|
showTitle = title,
|
||||||
showYear = parsedYear,
|
showYear = parsedYear,
|
||||||
showIds = ids,
|
showIds = ids,
|
||||||
season = seasonNumber,
|
season = mappedEpisode?.season ?: seasonNumber,
|
||||||
number = episodeNumber,
|
number = mappedEpisode?.episode ?: episodeNumber,
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -247,6 +256,9 @@ internal object TraktScrobbleRepository {
|
||||||
val isSameAction = last.action == action
|
val isSameAction = last.action == action
|
||||||
val isSameItem = last.itemKey == itemKey
|
val isSameItem = last.itemKey == itemKey
|
||||||
val isNearProgress = abs(last.progress - progress) <= progressWindow
|
val isNearProgress = abs(last.progress - progress) <= progressWindow
|
||||||
|
if (action == "stop" && last.action == "start" && isSameItem) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return isSameWindow && isSameAction && isSameItem && isNearProgress
|
return isSameWindow && isSameAction && isSameItem && isNearProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,14 @@ fun nextReleasedEpisodeAfter(
|
||||||
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
// Fallback: if the seed wasn't found by season+episode (anime with absolute
|
||||||
// numbering on Trakt vs multi-season on addon), try global index matching.
|
// numbering on Trakt vs multi-season on addon), try global index matching.
|
||||||
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
|
||||||
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
|
val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 }
|
||||||
|
val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
|
||||||
|
normalizeSeasonNumber(episode.seasonNumber)
|
||||||
|
}
|
||||||
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
|
||||||
val globalIndex = episodeNumber - 1
|
val globalIndex = episodeNumber - 1
|
||||||
if (globalIndex in sortedEpisodes.indices) {
|
if (globalIndex in mainEpisodes.indices) {
|
||||||
watchedIndex = globalIndex
|
watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences(
|
||||||
val blurNextUp: Boolean = false,
|
val blurNextUp: Boolean = false,
|
||||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||||
val showResumePromptOnLaunch: Boolean = true,
|
val showResumePromptOnLaunch: Boolean = true,
|
||||||
|
@SerialName("sort_mode")
|
||||||
|
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
||||||
)
|
)
|
||||||
|
|
||||||
object ContinueWatchingPreferencesRepository {
|
object ContinueWatchingPreferencesRepository {
|
||||||
|
|
@ -97,6 +99,7 @@ object ContinueWatchingPreferencesRepository {
|
||||||
blurNextUp = stored.blurNextUp,
|
blurNextUp = stored.blurNextUp,
|
||||||
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
||||||
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
||||||
|
sortMode = stored.sortMode,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContinueWatchingPreferencesUiState()
|
ContinueWatchingPreferencesUiState()
|
||||||
|
|
@ -155,6 +158,13 @@ object ContinueWatchingPreferencesRepository {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSortMode(mode: ContinueWatchingSortMode) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (_uiState.value.sortMode == mode) return
|
||||||
|
_uiState.value = _uiState.value.copy(sortMode = mode)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
fun removeDismissedNextUpKeysForContent(contentId: String) {
|
fun removeDismissedNextUpKeysForContent(contentId: String) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
val normalizedContentId = contentId.trim()
|
val normalizedContentId = contentId.trim()
|
||||||
|
|
@ -178,6 +188,7 @@ object ContinueWatchingPreferencesRepository {
|
||||||
blurNextUp = _uiState.value.blurNextUp,
|
blurNextUp = _uiState.value.blurNextUp,
|
||||||
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
||||||
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
||||||
|
sortMode = _uiState.value.sortMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ enum class ContinueWatchingSectionStyle {
|
||||||
Poster,
|
Poster,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ContinueWatchingSortMode {
|
||||||
|
DEFAULT,
|
||||||
|
STREAMING_STYLE,
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class WatchProgressEntry(
|
data class WatchProgressEntry(
|
||||||
val contentType: String,
|
val contentType: String,
|
||||||
|
|
@ -175,6 +181,7 @@ data class ContinueWatchingPreferencesUiState(
|
||||||
val blurNextUp: Boolean = false,
|
val blurNextUp: Boolean = false,
|
||||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||||
val showResumePromptOnLaunch: Boolean = true,
|
val showResumePromptOnLaunch: Boolean = true,
|
||||||
|
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun nextUpDismissKey(
|
internal fun nextUpDismissKey(
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,13 @@ package com.nuvio.app.features.collection
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
|
@ -178,4 +183,69 @@ class CollectionSourceSerializationTest {
|
||||||
assertTrue(merged.contains(""""customField":"keep-me""""))
|
assertTrue(merged.contains(""""customField":"keep-me""""))
|
||||||
assertTrue(merged.contains(""""traktListId":123456"""))
|
assertTrue(merged.contains(""""traktListId":123456"""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mobileGifToggleDoesNotEnterCollectionJsonOrOverwriteTvGifToggle() {
|
||||||
|
val raw = json.parseToJsonElement(
|
||||||
|
"""
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "collection-1",
|
||||||
|
"title": "Favorites",
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "folder-1",
|
||||||
|
"title": "Movies",
|
||||||
|
"coverImageUrl": "https://example.com/poster.jpg",
|
||||||
|
"focusGifUrl": "https://example.com/focus.gif",
|
||||||
|
"focusGifEnabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val collection = json.decodeFromString<List<Collection>>(raw.toString()).single()
|
||||||
|
val mobileDisabled = collection.copy(
|
||||||
|
folders = collection.folders.map { folder ->
|
||||||
|
folder.copy(mobileFocusGifEnabled = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled))
|
||||||
|
val mergedFolder = merged
|
||||||
|
.single()
|
||||||
|
.jsonObject["folders"]!!
|
||||||
|
.jsonArray
|
||||||
|
.single()
|
||||||
|
.jsonObject
|
||||||
|
|
||||||
|
assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean)
|
||||||
|
assertTrue(mergedFolder["mobileFocusGifEnabled"] == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mobileGifToggleDefaultsIndependentOfTvGifToggle() {
|
||||||
|
val payload = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "collection-1",
|
||||||
|
"title": "Favorites",
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "folder-1",
|
||||||
|
"title": "Movies",
|
||||||
|
"focusGifUrl": "https://example.com/focus.gif",
|
||||||
|
"focusGifEnabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val folder = json.decodeFromString<List<Collection>>(payload).single().folders.single()
|
||||||
|
|
||||||
|
assertFalse(folder.focusGifEnabled)
|
||||||
|
assertTrue(folder.mobileFocusGifEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,4 +88,31 @@ class SeriesPlaybackResolverTest {
|
||||||
assertEquals("Up Next • S1E3", action.label)
|
assertEquals("Up Next • S1E3", action.label)
|
||||||
assertEquals("show:1:3", action.videoId)
|
assertEquals("show:1:3", action.videoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
||||||
|
val meta = MetaDetails(
|
||||||
|
id = "show",
|
||||||
|
type = "series",
|
||||||
|
name = "Show",
|
||||||
|
videos = listOf(
|
||||||
|
MetaVideo(id = "sp1", title = "Special 1", season = 0, episode = 1, released = "2026-01-01"),
|
||||||
|
MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-08"),
|
||||||
|
MetaVideo(id = "s1e2", title = "Episode 2", season = 1, episode = 2, released = "2026-01-15"),
|
||||||
|
MetaVideo(id = "s2e1", title = "Episode 3", season = 2, episode = 1, released = "2026-01-22"),
|
||||||
|
MetaVideo(id = "s2e2", title = "Episode 4", season = 2, episode = 2, released = "2026-01-29"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextEpisode = meta.nextReleasedEpisodeAfter(
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 3,
|
||||||
|
todayIsoDate = "2026-02-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(nextEpisode)
|
||||||
|
assertEquals(2, nextEpisode.season)
|
||||||
|
assertEquals(2, nextEpisode.episode)
|
||||||
|
assertEquals("s2e2", nextEpisode.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.nuvio.app.features.streams
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
|
||||||
|
class StreamLinkCacheRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `movie cache key keeps legacy type and video id shape`() {
|
||||||
|
val key = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = "movie",
|
||||||
|
videoId = "tt123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("movie|tt123", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `episode cache key is scoped to parent show and episode`() {
|
||||||
|
val firstEpisode = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = "series",
|
||||||
|
videoId = "video-id",
|
||||||
|
parentMetaId = "tt999",
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
)
|
||||||
|
val secondEpisode = StreamLinkCacheRepository.contentKey(
|
||||||
|
type = "series",
|
||||||
|
videoId = "video-id",
|
||||||
|
parentMetaId = "tt999",
|
||||||
|
season = 1,
|
||||||
|
episode = 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotEquals(firstEpisode, secondEpisode)
|
||||||
|
assertEquals("series|tt999|s1|e1|video-id", firstEpisode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class TraktEpisodeMappingServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same structure compares per-season episode counts`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(2, 1),
|
||||||
|
)
|
||||||
|
val sameSeasonsDifferentCounts = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(2, 1),
|
||||||
|
episode(2, 2),
|
||||||
|
)
|
||||||
|
val sameCounts = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(2, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameSeasonsDifferentCounts))
|
||||||
|
assertTrue(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameCounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forward mapping uses global sorted index for anime numbering`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, videoId = "show:1:1"),
|
||||||
|
episode(1, 2, videoId = "show:1:2"),
|
||||||
|
episode(2, 1, videoId = "show:2:1"),
|
||||||
|
episode(2, 2, videoId = "show:2:2"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(1, 3),
|
||||||
|
episode(1, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 2,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(3, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reverse mapping uses global sorted index for Trakt absolute numbering`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(2, 1),
|
||||||
|
episode(2, 2),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
episode(1, 3),
|
||||||
|
episode(1, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.reverseRemapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 3,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, mapped?.season)
|
||||||
|
assertEquals(1, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unique normalized title wins over index`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, title = "The Storm"),
|
||||||
|
episode(1, 2, title = "Aftermath"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1, title = "Aftermath"),
|
||||||
|
episode(1, 2, title = "The Storm!"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(2, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generic title falls back to index`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, title = "Episode 1"),
|
||||||
|
episode(2, 1, title = "Actual Title"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1, title = "Actual Title"),
|
||||||
|
episode(1, 2, title = "Episode 1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(1, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate title falls back to index`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, title = "Pilot"),
|
||||||
|
episode(2, 1, title = "Other"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1, title = "Pilot"),
|
||||||
|
episode(1, 2, title = "Pilot"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(1, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `video id selects source episode before season episode`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1, videoId = "show:1:1"),
|
||||||
|
episode(2, 1, videoId = "show:2:1"),
|
||||||
|
)
|
||||||
|
val trakt = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 1,
|
||||||
|
requestedVideoId = "show:2:1",
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped?.season)
|
||||||
|
assertEquals(2, mapped?.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `index outside target range returns null`() {
|
||||||
|
val addon = listOf(
|
||||||
|
episode(1, 1),
|
||||||
|
episode(1, 2),
|
||||||
|
)
|
||||||
|
val trakt = listOf(episode(1, 1))
|
||||||
|
|
||||||
|
val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex(
|
||||||
|
requestedSeason = 1,
|
||||||
|
requestedEpisode = 2,
|
||||||
|
requestedVideoId = null,
|
||||||
|
requestedTitle = null,
|
||||||
|
addonEpisodes = addon,
|
||||||
|
traktEpisodes = trakt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episode(
|
||||||
|
season: Int,
|
||||||
|
episode: Int,
|
||||||
|
title: String? = null,
|
||||||
|
videoId: String? = null,
|
||||||
|
) = EpisodeMappingEntry(
|
||||||
|
season = season,
|
||||||
|
episode = episode,
|
||||||
|
title = title,
|
||||||
|
videoId = videoId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,30 @@ class SeriesContinuityTest {
|
||||||
assertEquals("show:1:1", action.videoId)
|
assertEquals("show:1:1", action.videoId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
|
||||||
|
val episodesWithSpecials = listOf(
|
||||||
|
WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-08"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-15"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s2e1", seasonNumber = 2, episodeNumber = 1, title = "Episode 3", releasedDate = "2026-01-22"),
|
||||||
|
WatchingReleasedEpisode(videoId = "s2e2", seasonNumber = 2, episodeNumber = 2, title = "Episode 4", releasedDate = "2026-01-29"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextEpisode = nextReleasedEpisodeAfter(
|
||||||
|
content = show,
|
||||||
|
episodes = episodesWithSpecials,
|
||||||
|
seasonNumber = 1,
|
||||||
|
episodeNumber = 3,
|
||||||
|
todayIsoDate = "2026-02-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(nextEpisode)
|
||||||
|
assertEquals(2, nextEpisode.seasonNumber)
|
||||||
|
assertEquals(2, nextEpisode.episodeNumber)
|
||||||
|
assertEquals("s2e2", nextEpisode.videoId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
|
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
|
||||||
val specialsOnly = listOf(
|
val specialsOnly = listOf(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"trakt_auth_payload",
|
"trakt_auth_payload",
|
||||||
"trakt_library_payload",
|
"trakt_library_payload",
|
||||||
"trakt_settings_payload",
|
"trakt_settings_payload",
|
||||||
|
"collection_mobile_settings_payload",
|
||||||
"collections_payload",
|
"collections_payload",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.nuvio.app.features.collection
|
||||||
|
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
|
actual object CollectionMobileSettingsStorage {
|
||||||
|
private const val payloadKey = "collection_mobile_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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components
|
||||||
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -51,6 +52,16 @@ private data class ExpandedGifFrames(
|
||||||
val tickCentiseconds: Int,
|
val tickCentiseconds: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private class GifImageViewHolder {
|
||||||
|
var imageView: UIImageView? = null
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
imageView?.stopAnimating()
|
||||||
|
imageView?.image = null
|
||||||
|
imageView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal actual fun CollectionCardRemoteImage(
|
internal actual fun CollectionCardRemoteImage(
|
||||||
|
|
@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage(
|
||||||
gifImage = loadGifImage(imageUrl)
|
gifImage = loadGifImage(imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val imageViewHolder = remember(imageUrl) { GifImageViewHolder() }
|
||||||
|
DisposableEffect(imageUrl) {
|
||||||
|
onDispose {
|
||||||
|
imageViewHolder.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UIKitView(
|
UIKitView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = {
|
factory = {
|
||||||
|
|
@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage(
|
||||||
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
|
contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
userInteractionEnabled = false
|
userInteractionEnabled = false
|
||||||
image = gifImage
|
|
||||||
tag = imageUrl.hashCode().toLong()
|
tag = imageUrl.hashCode().toLong()
|
||||||
|
imageViewHolder.imageView = this
|
||||||
|
updateGifImage(gifImage)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { imageView ->
|
update = { imageView ->
|
||||||
|
imageViewHolder.imageView = imageView
|
||||||
if (imageView.tag != imageUrl.hashCode().toLong()) {
|
if (imageView.tag != imageUrl.hashCode().toLong()) {
|
||||||
imageView.tag = imageUrl.hashCode().toLong()
|
imageView.tag = imageUrl.hashCode().toLong()
|
||||||
}
|
}
|
||||||
imageView.image = gifImage
|
imageView.updateGifImage(gifImage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun UIImageView.updateGifImage(image: UIImage?) {
|
||||||
|
if (this.image != image) {
|
||||||
|
stopAnimating()
|
||||||
|
this.image = image
|
||||||
|
}
|
||||||
|
if (image != null) {
|
||||||
|
startAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun cachedGifImage(imageUrl: String): UIImage? {
|
private fun cachedGifImage(imageUrl: String): UIImage? {
|
||||||
val image = gifImageCache[imageUrl] ?: return null
|
val image = gifImageCache[imageUrl] ?: return null
|
||||||
gifImageCacheOrder.remove(imageUrl)
|
gifImageCacheOrder.remove(imageUrl)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.nuvio.app.features.profiles
|
||||||
|
|
||||||
|
import platform.UIKit.UISelectionFeedbackGenerator
|
||||||
|
|
||||||
|
internal actual object ProfileHoverHapticFeedback {
|
||||||
|
private var generator: UISelectionFeedbackGenerator? = null
|
||||||
|
|
||||||
|
actual fun prepare() {
|
||||||
|
generator = UISelectionFeedbackGenerator().also { it.prepare() }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun perform() {
|
||||||
|
val activeGenerator = generator ?: UISelectionFeedbackGenerator().also {
|
||||||
|
generator = it
|
||||||
|
}
|
||||||
|
activeGenerator.selectionChanged()
|
||||||
|
activeGenerator.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun release() {
|
||||||
|
generator = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import nuvio.composeapp.generated.resources.Res
|
import nuvio.composeapp.generated.resources.Res
|
||||||
|
import nuvio.composeapp.generated.resources.introdb_favicon
|
||||||
import nuvio.composeapp.generated.resources.mdblist_logo
|
import nuvio.composeapp.generated.resources.mdblist_logo
|
||||||
import nuvio.composeapp.generated.resources.rating_tmdb
|
import nuvio.composeapp.generated.resources.rating_tmdb
|
||||||
import nuvio.composeapp.generated.resources.trakt_tv_favicon
|
import nuvio.composeapp.generated.resources.trakt_tv_favicon
|
||||||
|
|
@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter =
|
||||||
IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb)
|
IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb)
|
||||||
IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon)
|
IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon)
|
||||||
IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo)
|
IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo)
|
||||||
|
IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
package com.nuvio.app.features.trakt
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
import platform.Foundation.NSDate
|
import platform.Foundation.NSDate
|
||||||
import platform.Foundation.NSISO8601DateFormatter
|
|
||||||
import platform.Foundation.timeIntervalSince1970
|
import platform.Foundation.timeIntervalSince1970
|
||||||
|
|
||||||
internal actual object TraktPlatformClock {
|
internal actual object TraktPlatformClock {
|
||||||
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||||
|
|
||||||
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
actual fun parseIsoDateTimeToEpochMs(value: String): Long? =
|
||||||
NSISO8601DateFormatter()
|
parseTraktIsoDateTimeToEpochMs(value)
|
||||||
.dateFromString(value)
|
|
||||||
?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
#Kotlin
|
#Kotlin
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.daemon.jvmargs=-Xmx4096M
|
kotlin.daemon.jvmargs=-Xmx6144M
|
||||||
kotlin.native.jvmArgs=-Xmx6144M
|
kotlin.native.jvmArgs=-Xmx12288M
|
||||||
kotlin.mpp.enableCInteropCommonization=true
|
kotlin.mpp.enableCInteropCommonization=true
|
||||||
|
|
||||||
#Gradle
|
#Gradle
|
||||||
org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m
|
org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
||||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||||
|
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
|
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
|
||||||
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=54
|
CURRENT_PROJECT_VERSION=58
|
||||||
MARKETING_VERSION=0.1.0
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue