Merge branch 'cmp-rewrite' into patch-25

This commit is contained in:
AdityasahuX07 2026-05-12 15:17:30 +05:30 committed by GitHub
commit 4dc054e51e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 4568 additions and 313 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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 &amp; 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 &amp; 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&apos;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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.profiles
internal expect object ProfileHoverHapticFeedback {
fun prepare()
fun perform()
fun release()
}

View file

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

View file

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

View file

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

View file

@ -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,7 +285,15 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,
) )
} else { } else {
val normalizedQuery = query.trim()
val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery
when { when {
isWaitingForSearch -> {
items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
uiState.isLoading && uiState.sections.isEmpty() -> { uiState.isLoading && uiState.sections.isEmpty() -> {
items(2) { items(2) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
@ -291,7 +307,6 @@ fun SearchScreen(
errorMessage = uiState.errorMessage, errorMessage = uiState.errorMessage,
networkCondition = networkStatusUiState.condition, networkCondition = networkStatusUiState.condition,
onRetry = { onRetry = {
val normalizedQuery = query.trim()
if (normalizedQuery.isNotBlank()) { if (normalizedQuery.isNotBlank()) {
NetworkStatusRepository.requestRefresh(force = true) NetworkStatusRepository.requestRefresh(force = true)
SearchRepository.search( SearchRepository.search(
@ -300,6 +315,7 @@ fun SearchScreen(
) )
} }
}, },
modifier = Modifier.padding(horizontal = homeSectionPadding),
) )
} }
} }
@ -318,6 +334,11 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick, onPosterLongClick = onPosterLongClick,
) )
} }
if (uiState.isLoading) {
item(key = "search_loading_more") {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
}
}
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ internal enum class IntegrationLogo {
Tmdb, Tmdb,
Trakt, Trakt,
MdbList, MdbList,
IntroDb,
} }
@Composable @Composable

View file

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

View file

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

View file

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

View file

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

View file

@ -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,7 +431,18 @@ private fun MobileSettingsScreen(
} }
when (page) { when (page) {
SettingsPage.Root -> settingsRootContent( SettingsPage.Root -> {
settingsSearchRootContent(
query = settingsSearchQuery,
entries = searchEntries,
isTablet = false,
showSearchField = rootSearchVisible,
animateSearchField = rootSearchRevealAnimating,
onQueryChange = { settingsSearchQuery = it },
onTargetClick = { openSearchTarget(it) },
)
if (settingsSearchQuery.isBlank()) {
settingsRootContent(
isTablet = false, isTablet = false,
onPlaybackClick = { onPageChange(SettingsPage.Playback) }, onPlaybackClick = { onPageChange(SettingsPage.Playback) },
onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
@ -347,17 +451,23 @@ private fun MobileSettingsScreen(
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick, onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick, onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile, 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) {
if (settingsSearchQuery.isBlank()) {
stringResource(activeCategory.labelRes) stringResource(activeCategory.labelRes)
} else {
stringResource(Res.string.compose_settings_page_root)
}
} else { } else {
stringResource(page.titleRes) stringResource(page.titleRes)
}, },
@ -585,7 +788,18 @@ private fun TabletSettingsScreen(
) )
} }
when (page) { when (page) {
SettingsPage.Root -> settingsRootContent( SettingsPage.Root -> {
settingsSearchRootContent(
query = settingsSearchQuery,
entries = searchEntries,
isTablet = true,
showSearchField = rootSearchVisible,
animateSearchField = rootSearchRevealAnimating,
onQueryChange = { settingsSearchQuery = it },
onTargetClick = { openSearchTarget(it) },
)
if (settingsSearchQuery.isBlank()) {
settingsRootContent(
isTablet = true, isTablet = true,
onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, onPlaybackClick = { openInlinePage(SettingsPage.Playback) },
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
@ -594,6 +808,7 @@ private fun TabletSettingsScreen(
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) },
onCheckForUpdatesClick = onCheckForUpdatesClick, onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) }, onAccountClick = { openInlinePage(SettingsPage.Account) },
@ -602,12 +817,17 @@ private fun TabletSettingsScreen(
showGeneralSection = activeCategory == SettingsCategory.General, showGeneralSection = activeCategory == SettingsCategory.General,
showAboutSection = activeCategory == SettingsCategory.About, 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(

View file

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

View file

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

View file

@ -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,
) )
@ -153,6 +172,7 @@ object StreamsRepository {
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,

View file

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

View file

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

View file

@ -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,
requestedEpisode = requestedEpisode,
requestedVideoId = null,
requestedTitle = requestedTitle,
sourceEpisodes = traktEpisodes,
targetEpisodes = addonEpisodes,
)
}
private fun remapEpisodeBetweenLists(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
requestedTitle: String?,
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 it.season == requestedSeason && it.episode == requestedEpisode
} ?: return null
// Try title match first
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
if (!titleToMatch.isNullOrBlank()) {
val titleMatch = addonEpisodes.firstOrNull { target ->
!target.title.isNullOrBlank() &&
normalizeTitle(target.title) == normalizeTitle(titleToMatch)
} }
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 ────────────────────────────────────────────────────────

View file

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

View file

@ -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) {
val remapped = sorted[globalIndex]
resolvedSeason = remapped.season resolvedSeason = remapped.season
resolvedEpisode = remapped.episode resolvedEpisode = remapped.episode
remapped meta.videos.firstOrNull { video ->
} else null video.season == remapped.season && video.episode == remapped.episode
} else null }
} else {
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=54 CURRENT_PROJECT_VERSION=58
MARKETING_VERSION=0.1.0 MARKETING_VERSION=0.1.0