From 24692a4d3a21e64fb7646acaaad80ada62072018 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 18:39:40 +0530 Subject: [PATCH 01/14] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 965f9e75..e29881ba 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=54 -MARKETING_VERSION=0.1.0 +CURRENT_PROJECT_VERSION=55 +MARKETING_VERSION=0.1.16 From 2db039e24907555f2909434a2c157c5191e6bb60 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 8 May 2026 20:15:43 +0530 Subject: [PATCH 02/14] feat: adding search option on settings screen --- .../composeResources/values/strings.xml | 3 + .../app/features/settings/SettingsScreen.kt | 258 ++++- .../app/features/settings/SettingsSearch.kt | 971 ++++++++++++++++++ iosApp/Configuration/Version.xcconfig | 2 +- 4 files changed, 1199 insertions(+), 35 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f235570b..78461786 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -391,6 +391,9 @@ Change to a different profile. Switch Profile Open Trakt connection screen + No settings found. + Search settings... + RESULTS Loading your Trakt lists… Choose where to save this title on Trakt Donate diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index b625c9dc..45a52feb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -29,10 +30,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.geometry.Offset 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.unit.dp import androidx.compose.ui.unit.max @@ -66,8 +76,14 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_root +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource +private val SettingsSearchRevealThreshold = 28.dp +private const val SettingsSearchRevealAnimationMillis = 240L +private const val SettingsSearchRevealHapticDelayMillis = 90L + @Composable fun SettingsScreen( modifier: Modifier = Modifier, @@ -328,7 +344,66 @@ private fun MobileSettingsScreen( ) { val saveableStateHolder = rememberSaveableStateHolder() 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.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 { val previousPage = page.previousPage() NuvioScreenHeader( @@ -338,20 +413,33 @@ private fun MobileSettingsScreen( } when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = false, - onPlaybackClick = { onPageChange(SettingsPage.Playback) }, - onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, - onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, - onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, - onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = onSupportersContributorsClick, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = onAccountClick, - onSwitchProfileClick = onSwitchProfile, - ) + 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, + onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, + onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, + onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, + onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = onSupportersContributorsClick, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = onAccountClick, + onSwitchProfileClick = onSwitchProfile, + ) + } + } SettingsPage.Account -> accountSettingsContent( isTablet = false, ) @@ -453,6 +541,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 private fun TabletSettingsScreen( page: SettingsPage, @@ -559,11 +689,54 @@ private fun TabletSettingsScreen( } 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 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( state = listState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .nestedScroll(rootSearchRevealConnection), contentPadding = PaddingValues( start = 40.dp, top = topOffset, @@ -576,7 +749,11 @@ private fun TabletSettingsScreen( val previousPage = page.previousPage() TabletPageHeader( title = if (page == SettingsPage.Root) { - stringResource(activeCategory.labelRes) + if (settingsSearchQuery.isBlank()) { + stringResource(activeCategory.labelRes) + } else { + stringResource(Res.string.compose_settings_page_root) + } } else { stringResource(page.titleRes) }, @@ -585,23 +762,36 @@ private fun TabletSettingsScreen( ) } when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = true, - onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, - onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, - onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, - onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, - onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = { openInlinePage(SettingsPage.Account) }, - onSwitchProfileClick = onSwitchProfile, - showAccountSection = activeCategory == SettingsCategory.Account, - showGeneralSection = activeCategory == SettingsCategory.General, - showAboutSection = activeCategory == SettingsCategory.About, - ) + 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, + onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, + onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, + onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, + onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, + onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = { openInlinePage(SettingsPage.Account) }, + onSwitchProfileClick = onSwitchProfile, + showAccountSection = activeCategory == SettingsCategory.Account, + showGeneralSection = activeCategory == SettingsCategory.General, + showAboutSection = activeCategory == SettingsCategory.About, + ) + } + } SettingsPage.Account -> accountSettingsContent( isTablet = true, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt new file mode 100644 index 00000000..1a8a2e56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -0,0 +1,971 @@ +package com.nuvio.app.features.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CollectionsBookmark +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.People +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Style +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.isIos +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +internal sealed class SettingsSearchTarget { + data class Page(val page: SettingsPage) : SettingsSearchTarget() + object Downloads : SettingsSearchTarget() + object Collections : SettingsSearchTarget() + object SwitchProfile : SettingsSearchTarget() + object CheckForUpdates : SettingsSearchTarget() +} + +internal data class SettingsSearchEntry( + val key: String, + val title: String, + val description: String, + val page: String, + val section: String, + val category: String, + val icon: ImageVector, + val target: SettingsSearchTarget, +) { + val searchableText: String = listOf(title, description, page, section, category) + .joinToString(separator = " ") + .lowercase() + + val contextLabel: String = listOf(page, section) + .filter { it.isNotBlank() } + .distinct() + .joinToString(separator = " - ") +} + +@Composable +internal fun settingsSearchEntries( + pluginsEnabled: Boolean, + liquidGlassNativeTabBarSupported: Boolean, + switchProfileAvailable: Boolean, + checkForUpdatesAvailable: Boolean, +): List { + val accountCategory = stringResource(SettingsCategory.Account.labelRes) + val generalCategory = stringResource(SettingsCategory.General.labelRes) + val aboutCategory = stringResource(SettingsCategory.About.labelRes) + + val accountPage = stringResource(Res.string.compose_settings_page_account) + val traktPage = stringResource(Res.string.compose_settings_page_trakt) + val layoutPage = stringResource(Res.string.compose_settings_page_appearance) + val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) + val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title) + val playbackPage = stringResource(Res.string.compose_settings_page_playback) + val integrationsPage = stringResource(Res.string.compose_settings_page_integrations) + val notificationsPage = stringResource(Res.string.compose_settings_page_notifications) + val supportersPage = stringResource(Res.string.compose_settings_page_supporters_contributors) + val homeLayoutPage = stringResource(Res.string.compose_settings_page_homescreen) + val detailPage = stringResource(Res.string.compose_settings_page_meta_screen) + val continueWatchingPage = stringResource(Res.string.compose_settings_page_continue_watching) + val posterStylePage = stringResource(Res.string.compose_settings_page_poster_customization) + val addonsPage = stringResource(Res.string.compose_settings_page_addons) + val pluginsPage = stringResource(Res.string.compose_settings_page_plugins) + val collectionsPage = stringResource(Res.string.collections_header) + val tmdbPage = stringResource(Res.string.compose_settings_page_tmdb_enrichment) + val mdbListPage = stringResource(Res.string.compose_settings_page_mdblist_ratings) + + val entries = mutableListOf() + + fun add( + key: String, + title: String, + description: String = "", + page: String = title, + section: String = "", + category: String = generalCategory, + icon: ImageVector, + target: SettingsSearchTarget, + ) { + entries += SettingsSearchEntry( + key = key, + title = title, + description = description, + page = page, + section = section, + category = category, + icon = icon, + target = target, + ) + } + + fun addPage( + page: SettingsPage, + key: String, + title: String, + description: String, + category: String = generalCategory, + icon: ImageVector, + ) { + add( + key = key, + title = title, + description = description, + page = title, + category = category, + icon = icon, + target = SettingsSearchTarget.Page(page), + ) + } + + fun addRow( + page: SettingsPage, + key: String, + title: String, + description: String = "", + pageLabel: String, + section: String, + category: String = generalCategory, + icon: ImageVector, + ) { + add( + key = key, + title = title, + description = description, + page = pageLabel, + section = section, + category = category, + icon = icon, + target = SettingsSearchTarget.Page(page), + ) + } + + if (switchProfileAvailable) { + add( + key = "switch-profile", + title = stringResource(Res.string.compose_settings_root_switch_profile_title), + description = stringResource(Res.string.compose_settings_root_switch_profile_description), + page = accountPage, + section = stringResource(Res.string.compose_settings_root_account_section), + category = accountCategory, + icon = Icons.Rounded.People, + target = SettingsSearchTarget.SwitchProfile, + ) + } + addPage( + page = SettingsPage.Account, + key = "account", + title = accountPage, + description = stringResource(Res.string.compose_settings_root_account_description), + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addPage( + page = SettingsPage.TraktAuthentication, + key = "trakt", + title = traktPage, + description = stringResource(Res.string.compose_settings_root_trakt_description), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.Appearance, + key = "layout", + title = layoutPage, + description = stringResource(Res.string.compose_settings_root_appearance_description), + icon = Icons.Rounded.Palette, + ) + addPage( + page = SettingsPage.ContentDiscovery, + key = "content-discovery", + title = contentDiscoveryPage, + description = stringResource(Res.string.compose_settings_root_content_discovery_description), + icon = Icons.Rounded.Extension, + ) + add( + key = "downloads", + title = downloadsPage, + description = stringResource(Res.string.compose_settings_root_downloads_description), + category = generalCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Downloads, + ) + addPage( + page = SettingsPage.Playback, + key = "playback", + title = playbackPage, + description = stringResource(Res.string.settings_playback_subtitle), + icon = Icons.Rounded.PlayArrow, + ) + addPage( + page = SettingsPage.Integrations, + key = "integrations", + title = integrationsPage, + description = stringResource(Res.string.compose_settings_root_integrations_description), + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.Notifications, + key = "notifications", + title = notificationsPage, + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + ) + addPage( + page = SettingsPage.SupportersContributors, + key = "supporters", + title = supportersPage, + description = stringResource(Res.string.about_supporters_contributors_subtitle), + category = aboutCategory, + icon = Icons.Rounded.Favorite, + ) + if (checkForUpdatesAvailable) { + add( + key = "check-updates", + title = stringResource(Res.string.compose_settings_root_check_updates_title), + description = stringResource(Res.string.compose_settings_root_check_updates_description), + page = supportersPage, + section = stringResource(Res.string.compose_settings_root_about_section), + category = aboutCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.CheckForUpdates, + ) + } + + addRow( + page = SettingsPage.Account, + key = "account-status", + title = stringResource(Res.string.settings_account_status), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addRow( + page = SettingsPage.Account, + key = "account-sign-out", + title = stringResource(Res.string.settings_account_sign_out), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addRow( + page = SettingsPage.Account, + key = "account-delete", + title = stringResource(Res.string.settings_account_delete_account), + description = stringResource(Res.string.settings_account_delete_account_description), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + + addRow( + page = SettingsPage.Appearance, + key = "theme", + title = stringResource(Res.string.settings_appearance_section_theme), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_theme), + icon = Icons.Rounded.Palette, + ) + addRow( + page = SettingsPage.Appearance, + key = "amoled", + title = stringResource(Res.string.settings_appearance_amoled_black), + description = stringResource(Res.string.settings_appearance_amoled_description), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Palette, + ) + if (liquidGlassNativeTabBarSupported) { + addRow( + page = SettingsPage.Appearance, + key = "liquid-glass", + title = stringResource(Res.string.settings_appearance_liquid_glass), + description = stringResource(Res.string.settings_appearance_liquid_glass_description), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Palette, + ) + } + addRow( + page = SettingsPage.Appearance, + key = "app-language", + title = stringResource(Res.string.settings_appearance_app_language), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Language, + ) + addPage( + page = SettingsPage.ContinueWatching, + key = "continue-watching", + title = continueWatchingPage, + description = stringResource(Res.string.settings_appearance_continue_watching_description), + icon = Icons.Rounded.Style, + ) + addPage( + page = SettingsPage.PosterCustomization, + key = "poster-card-style", + title = posterStylePage, + description = stringResource(Res.string.settings_appearance_poster_customization_description), + icon = Icons.Rounded.Tune, + ) + + addPage( + page = SettingsPage.Addons, + key = "addons", + title = addonsPage, + description = stringResource(Res.string.settings_content_discovery_addons_description), + icon = Icons.Rounded.Extension, + ) + if (pluginsEnabled) { + addPage( + page = SettingsPage.Plugins, + key = "plugins", + title = pluginsPage, + description = stringResource(Res.string.settings_content_discovery_plugins_description), + icon = Icons.Rounded.Hub, + ) + } + addPage( + page = SettingsPage.Homescreen, + key = "home-layout", + title = homeLayoutPage, + description = stringResource(Res.string.settings_content_discovery_homescreen_description), + icon = Icons.Rounded.Home, + ) + addPage( + page = SettingsPage.MetaScreen, + key = "detail-page", + title = detailPage, + description = stringResource(Res.string.settings_content_discovery_meta_screen_description), + icon = Icons.Rounded.Tune, + ) + add( + key = "collections", + title = collectionsPage, + description = stringResource(Res.string.settings_content_discovery_collections_description), + page = contentDiscoveryPage, + section = stringResource(Res.string.settings_content_discovery_section_home), + category = generalCategory, + icon = Icons.Rounded.CollectionsBookmark, + target = SettingsSearchTarget.Collections, + ) + + val playbackPlayer = stringResource(Res.string.settings_playback_section_player) + val playbackSubtitleAudio = stringResource(Res.string.settings_playback_section_subtitle_audio) + val playbackStreamSelection = stringResource(Res.string.settings_playback_section_stream_selection) + val playbackStreamAutoPlay = stringResource(Res.string.settings_playback_section_stream_auto_play) + val playbackDecoder = stringResource(Res.string.settings_playback_section_decoder) + val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) + val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) + val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackPlayer, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow( + "loading-overlay", + stringResource(Res.string.settings_playback_show_loading_overlay), + stringResource(Res.string.settings_playback_show_loading_overlay_description), + ), + PlaybackSearchRow( + "hold-to-speed", + stringResource(Res.string.settings_playback_hold_to_speed), + stringResource(Res.string.settings_playback_hold_to_speed_description), + ), + PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSubtitleAudio, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("preferred-audio", stringResource(Res.string.settings_playback_preferred_audio_language)), + PlaybackSearchRow("secondary-audio", stringResource(Res.string.settings_playback_secondary_audio_language)), + PlaybackSearchRow("preferred-subtitles", stringResource(Res.string.settings_playback_preferred_subtitle_language)), + PlaybackSearchRow("secondary-subtitles", stringResource(Res.string.settings_playback_secondary_subtitle_language)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackStreamSelection, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow( + "reuse-last-link", + stringResource(Res.string.settings_playback_reuse_last_link), + stringResource(Res.string.settings_playback_reuse_last_link_description), + ), + PlaybackSearchRow("last-link-cache", stringResource(Res.string.settings_playback_last_link_cache_duration)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackStreamAutoPlay, + icon = Icons.Rounded.PlayArrow, + rows = buildList { + add(PlaybackSearchRow("stream-mode", stringResource(Res.string.settings_playback_stream_selection_mode))) + add(PlaybackSearchRow("regex-pattern", stringResource(Res.string.settings_playback_regex_pattern))) + add(PlaybackSearchRow("stream-timeout", stringResource(Res.string.settings_playback_stream_timeout), stringResource(Res.string.settings_playback_stream_timeout_description))) + add(PlaybackSearchRow("source-scope", stringResource(Res.string.settings_playback_source_scope))) + add(PlaybackSearchRow("allowed-addons", stringResource(Res.string.settings_playback_allowed_addons))) + if (pluginsEnabled) add(PlaybackSearchRow("allowed-plugins", stringResource(Res.string.settings_playback_allowed_plugins))) + }, + ) + if (!isIos) { + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackDecoder, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("decoder-priority", stringResource(Res.string.settings_playback_decoder_priority)), + PlaybackSearchRow("dv7-hevc", stringResource(Res.string.settings_playback_map_dv7_to_hevc), stringResource(Res.string.settings_playback_map_dv7_to_hevc_description)), + PlaybackSearchRow("tunneled-playback", stringResource(Res.string.settings_playback_tunneled_playback), stringResource(Res.string.settings_playback_tunneled_playback_description)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSubtitleRendering, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("libass", stringResource(Res.string.settings_playback_enable_libass), stringResource(Res.string.settings_playback_enable_libass_description)), + PlaybackSearchRow("libass-render", stringResource(Res.string.settings_playback_render_type)), + ), + ) + } + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSkipSegments, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("skip-intro", stringResource(Res.string.settings_playback_skip_intro_outro_recap), stringResource(Res.string.settings_playback_skip_intro_outro_recap_description)), + PlaybackSearchRow("anime-skip", stringResource(Res.string.settings_playback_anime_skip), stringResource(Res.string.settings_playback_anime_skip_description)), + PlaybackSearchRow("anime-skip-client", stringResource(Res.string.settings_playback_anime_skip_client_id), stringResource(Res.string.settings_playback_anime_skip_client_id_description)), + PlaybackSearchRow("intro-submit", stringResource(Res.string.settings_playback_intro_submit_enabled), stringResource(Res.string.settings_playback_intro_submit_enabled_description)), + PlaybackSearchRow("introdb-key", stringResource(Res.string.settings_playback_introdb_api_key), stringResource(Res.string.settings_playback_introdb_api_key_description)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackNextEpisode, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("auto-play-next", stringResource(Res.string.settings_playback_auto_play_next_episode), stringResource(Res.string.settings_playback_auto_play_next_episode_description)), + PlaybackSearchRow("prefer-binge", stringResource(Res.string.settings_playback_prefer_binge_group), stringResource(Res.string.settings_playback_prefer_binge_group_description)), + PlaybackSearchRow("threshold-mode", stringResource(Res.string.settings_playback_threshold_mode)), + PlaybackSearchRow("threshold-percent", stringResource(Res.string.settings_playback_threshold_percentage), stringResource(Res.string.settings_playback_threshold_percentage_description)), + PlaybackSearchRow("threshold-minutes", stringResource(Res.string.settings_playback_minutes_before_end), stringResource(Res.string.settings_playback_minutes_before_end_description)), + ), + ) + + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_visibility), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow( + "show-continue-watching", + stringResource(Res.string.settings_continue_watching_show_title), + stringResource(Res.string.settings_continue_watching_show_description), + ), + ), + ) + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_up_next_behavior), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow("episode-thumbnails", stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description)), + PlaybackSearchRow("up-next", stringResource(Res.string.settings_continue_watching_up_next_title), stringResource(Res.string.settings_continue_watching_up_next_description)), + PlaybackSearchRow("unaired-next-up", stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description)), + PlaybackSearchRow("blur-next-up", stringResource(Res.string.settings_continue_watching_blur_next_up_title), stringResource(Res.string.settings_continue_watching_blur_next_up_description)), + ), + ) + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_on_launch), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow("resume-prompt", stringResource(Res.string.settings_continue_watching_resume_prompt_title), stringResource(Res.string.settings_continue_watching_resume_prompt_description)), + ), + ) + + val posterSection = stringResource(Res.string.settings_poster_card_style) + listOf( + PlaybackSearchRow("poster-width", stringResource(Res.string.settings_poster_card_width)), + PlaybackSearchRow("poster-radius", stringResource(Res.string.settings_poster_card_radius)), + PlaybackSearchRow("poster-landscape", stringResource(Res.string.settings_poster_landscape_mode)), + PlaybackSearchRow("poster-hide-labels", stringResource(Res.string.settings_poster_hide_labels)), + ).forEach { row -> + addRow( + page = SettingsPage.PosterCustomization, + key = "poster-${row.key}", + title = row.title, + description = row.description, + pageLabel = posterStylePage, + section = posterSection, + icon = Icons.Rounded.Tune, + ) + } + + val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero) + listOf( + PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)), + PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)), + PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)), + PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)), + ).forEach { row -> + addRow( + page = SettingsPage.Homescreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = homeLayoutPage, + section = homeLayoutSection, + icon = Icons.Rounded.Home, + ) + } + + val detailAppearanceSection = stringResource(Res.string.settings_meta_section_appearance) + listOf( + PlaybackSearchRow("meta-cinematic", stringResource(Res.string.settings_meta_cinematic_background), stringResource(Res.string.settings_meta_cinematic_background_description)), + PlaybackSearchRow("meta-tabs", stringResource(Res.string.settings_meta_tab_layout), stringResource(Res.string.settings_meta_tab_layout_description)), + PlaybackSearchRow("meta-episode-cards", stringResource(Res.string.settings_meta_episode_cards), stringResource(Res.string.settings_meta_episode_cards_description)), + PlaybackSearchRow("meta-blur-episodes", stringResource(Res.string.settings_meta_blur_unwatched_episodes), stringResource(Res.string.settings_meta_blur_unwatched_episodes_description)), + ).forEach { row -> + addRow( + page = SettingsPage.MetaScreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = detailPage, + section = detailAppearanceSection, + icon = Icons.Rounded.Tune, + ) + } + val detailSectionsSection = stringResource(Res.string.settings_meta_section_sections) + listOf( + PlaybackSearchRow("meta-overview", stringResource(Res.string.settings_meta_overview), stringResource(Res.string.settings_meta_overview_description)), + PlaybackSearchRow("meta-actions", stringResource(Res.string.settings_meta_actions), stringResource(Res.string.settings_meta_actions_description)), + PlaybackSearchRow("meta-details", stringResource(Res.string.settings_meta_details), stringResource(Res.string.settings_meta_details_description)), + PlaybackSearchRow("meta-trailers", stringResource(Res.string.settings_meta_trailers), stringResource(Res.string.settings_meta_trailers_description)), + PlaybackSearchRow("meta-cast", stringResource(Res.string.settings_meta_cast), stringResource(Res.string.settings_meta_cast_description)), + PlaybackSearchRow("meta-episodes", stringResource(Res.string.settings_meta_episodes), stringResource(Res.string.settings_meta_episodes_description)), + PlaybackSearchRow("meta-production", stringResource(Res.string.settings_meta_production), stringResource(Res.string.settings_meta_production_description)), + PlaybackSearchRow("meta-more-like-this", stringResource(Res.string.settings_meta_more_like_this), stringResource(Res.string.settings_meta_more_like_this_description)), + PlaybackSearchRow("meta-collection", stringResource(Res.string.settings_meta_collection), stringResource(Res.string.settings_meta_collection_description)), + PlaybackSearchRow("meta-comments", stringResource(Res.string.settings_meta_comments), stringResource(Res.string.settings_meta_comments_description)), + ).forEach { row -> + addRow( + page = SettingsPage.MetaScreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = detailPage, + section = detailSectionsSection, + icon = Icons.Rounded.Tune, + ) + } + + addPage( + page = SettingsPage.TmdbEnrichment, + key = "tmdb", + title = tmdbPage, + description = stringResource(Res.string.settings_integrations_tmdb_description), + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.MdbListRatings, + key = "mdblist", + title = mdbListPage, + description = stringResource(Res.string.settings_integrations_mdblist_description), + icon = Icons.Rounded.Link, + ) + val tmdbModulesSection = stringResource(Res.string.settings_tmdb_section_modules) + listOf( + PlaybackSearchRow("tmdb-enable", stringResource(Res.string.settings_tmdb_enable_enrichment), stringResource(Res.string.settings_tmdb_enable_enrichment_description), stringResource(Res.string.settings_tmdb_section_title)), + PlaybackSearchRow("tmdb-api-key", stringResource(Res.string.settings_tmdb_personal_api_key), "", stringResource(Res.string.settings_tmdb_section_credentials)), + PlaybackSearchRow("tmdb-language", stringResource(Res.string.settings_tmdb_preferred_language), stringResource(Res.string.settings_tmdb_preferred_language_description), stringResource(Res.string.settings_tmdb_section_localization)), + PlaybackSearchRow("tmdb-trailers", stringResource(Res.string.settings_tmdb_module_trailers), stringResource(Res.string.settings_tmdb_module_trailers_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-artwork", stringResource(Res.string.settings_tmdb_module_artwork), stringResource(Res.string.settings_tmdb_module_artwork_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-basic-info", stringResource(Res.string.settings_tmdb_module_basic_info), stringResource(Res.string.settings_tmdb_module_basic_info_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-details", stringResource(Res.string.settings_tmdb_module_details), stringResource(Res.string.settings_tmdb_module_details_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-credits", stringResource(Res.string.settings_tmdb_module_credits), stringResource(Res.string.settings_tmdb_module_credits_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-companies", stringResource(Res.string.settings_tmdb_module_production_companies), stringResource(Res.string.settings_tmdb_module_production_companies_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-networks", stringResource(Res.string.settings_tmdb_module_networks), stringResource(Res.string.settings_tmdb_module_networks_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-episodes", stringResource(Res.string.settings_tmdb_module_episodes), stringResource(Res.string.settings_tmdb_module_episodes_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-season-posters", stringResource(Res.string.settings_tmdb_module_season_posters), stringResource(Res.string.settings_tmdb_module_season_posters_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-more-like-this", stringResource(Res.string.settings_tmdb_module_more_like_this), stringResource(Res.string.settings_tmdb_module_more_like_this_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-collections", stringResource(Res.string.settings_tmdb_module_collections), stringResource(Res.string.settings_tmdb_module_collections_description), tmdbModulesSection), + ).forEach { row -> + addRow( + page = SettingsPage.TmdbEnrichment, + key = row.key, + title = row.title, + description = row.description, + pageLabel = tmdbPage, + section = row.sectionOverride ?: tmdbModulesSection, + icon = Icons.Rounded.Link, + ) + } + + listOf( + PlaybackSearchRow("mdb-enable", stringResource(Res.string.settings_mdb_enable_ratings), stringResource(Res.string.settings_mdb_enable_ratings_description), stringResource(Res.string.settings_mdb_section_title)), + PlaybackSearchRow("mdb-api-key", stringResource(Res.string.settings_mdb_api_key_title), stringResource(Res.string.settings_mdb_api_key_description), stringResource(Res.string.settings_mdb_section_api_key)), + PlaybackSearchRow("mdb-imdb", stringResource(Res.string.source_imdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-tmdb", stringResource(Res.string.source_tmdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-tomatoes", stringResource(Res.string.source_rotten_tomatoes), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-metacritic", stringResource(Res.string.source_metacritic), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-trakt", stringResource(Res.string.source_trakt), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-letterboxd", stringResource(Res.string.source_letterboxd), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-audience", stringResource(Res.string.source_audience_score), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + ).forEach { row -> + addRow( + page = SettingsPage.MdbListRatings, + key = row.key, + title = row.title, + description = row.description, + pageLabel = mdbListPage, + section = row.sectionOverride ?: stringResource(Res.string.settings_mdb_section_title), + icon = Icons.Rounded.Link, + ) + } + + val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) + addRow( + page = SettingsPage.Notifications, + key = "episode-release-alerts", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), + pageLabel = notificationsPage, + section = notificationsAlerts, + icon = Icons.Rounded.Notifications, + ) + addRow( + page = SettingsPage.Notifications, + key = "notification-test", + title = stringResource(Res.string.settings_notifications_test_title), + pageLabel = notificationsPage, + section = stringResource(Res.string.settings_notifications_section_test), + icon = Icons.Rounded.Notifications, + ) + + addRow( + page = SettingsPage.TraktAuthentication, + key = "trakt-authentication", + title = stringResource(Res.string.settings_trakt_authentication), + description = stringResource(Res.string.settings_trakt_intro_description), + pageLabel = traktPage, + section = stringResource(Res.string.settings_trakt_authentication), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + listOf( + PlaybackSearchRow("trakt-library-source", stringResource(Res.string.trakt_library_source_title), stringResource(Res.string.trakt_library_source_subtitle)), + PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)), + PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)), + PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)), + ).forEach { row -> + addRow( + page = SettingsPage.TraktAuthentication, + key = row.key, + title = row.title, + description = row.description, + pageLabel = traktPage, + section = stringResource(Res.string.settings_trakt_features), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + } + + return entries +} + +private data class PlaybackSearchRow( + val key: String, + val title: String, + val description: String = "", + val sectionOverride: String? = null, +) + +private fun addPlaybackRows( + addRow: ( + page: SettingsPage, + key: String, + title: String, + description: String, + pageLabel: String, + section: String, + category: String, + icon: ImageVector, + ) -> Unit, + pageLabel: String, + section: String, + icon: ImageVector, + rows: List, +) { + rows.forEach { row -> + addRow( + SettingsPage.Playback, + "playback-${row.key}", + row.title, + row.description, + pageLabel, + section, + "", + icon, + ) + } +} + +private fun addContinueWatchingRows( + addRow: ( + page: SettingsPage, + key: String, + title: String, + description: String, + pageLabel: String, + section: String, + category: String, + icon: ImageVector, + ) -> Unit, + pageLabel: String, + section: String, + icon: ImageVector, + rows: List, +) { + rows.forEach { row -> + addRow( + SettingsPage.ContinueWatching, + "continue-watching-${row.key}", + row.title, + row.description, + pageLabel, + section, + "", + icon, + ) + } +} + +internal fun LazyListScope.settingsSearchRootContent( + query: String, + entries: List, + isTablet: Boolean, + showSearchField: Boolean, + animateSearchField: Boolean, + onQueryChange: (String) -> Unit, + onTargetClick: (SettingsSearchTarget) -> Unit, +) { + if (showSearchField || query.isNotBlank()) { + item(key = "settings-search-field") { + SettingsSearchRevealItem(animate = animateSearchField) { + SettingsSearchField( + query = query, + onQueryChange = onQueryChange, + ) + } + } + } + + if (query.isBlank()) return + + val results = settingsSearchResults( + query = query, + entries = entries, + ) + + item(key = "settings-search-results") { + if (results.isEmpty()) { + SettingsSearchEmptyState(isTablet = isTablet) + } else { + SettingsSection( + title = stringResource(Res.string.settings_search_results_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + results.forEachIndexed { index, entry -> + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + SettingsNavigationRow( + title = entry.title, + description = entry.resultDescription(), + icon = entry.icon, + isTablet = isTablet, + onClick = { onTargetClick(entry.target) }, + ) + } + } + } + } + } +} + +@Composable +private fun SettingsSearchRevealItem( + animate: Boolean, + content: @Composable () -> Unit, +) { + if (!animate) { + content() + return + } + + val visibleState = remember { + MutableTransitionState(false).apply { + targetState = true + } + } + AnimatedVisibility( + visibleState = visibleState, + enter = expandVertically( + animationSpec = tween(durationMillis = 220), + expandFrom = Alignment.Top, + ) + fadeIn( + animationSpec = tween(durationMillis = 180), + ) + slideInVertically( + animationSpec = tween(durationMillis = 220), + initialOffsetY = { -it / 4 }, + ), + ) { + content() + } +} + +@Composable +private fun SettingsSearchField( + query: String, + onQueryChange: (String) -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = if (query.isNotBlank()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + placeholder = { + Text( + text = stringResource(Res.string.settings_search_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge, + ) + }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) +} + +@Composable +private fun SettingsSearchEmptyState(isTablet: Boolean) { + SettingsSection( + title = stringResource(Res.string.settings_search_results_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 18.dp), + ) { + Text( + text = stringResource(Res.string.settings_search_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +private fun settingsSearchResults( + query: String, + entries: List, +): List { + val terms = query + .trim() + .lowercase() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + + if (terms.isEmpty()) return emptyList() + + return entries.filter { entry -> + terms.all { term -> entry.searchableText.contains(term) } + } +} + +private fun SettingsSearchEntry.resultDescription(): String { + return description.ifBlank { contextLabel } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index e29881ba..7e78cee8 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=55 -MARKETING_VERSION=0.1.16 +MARKETING_VERSION=0.1.0 From 7290158c53bec2ea34e3114a55e42f32eb34387e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 00:27:34 +0530 Subject: [PATCH 03/14] fix(ios): trakt date parsing logic causing old shows to appear first cw section --- .../trakt/TraktPlatformClock.android.kt | 6 +- .../app/features/trakt/TraktIsoDateParser.kt | 70 +++++++++++++++++++ .../features/trakt/TraktPlatformClock.ios.kt | 5 +- 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt index 4eda3a91..585cb863 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt @@ -5,7 +5,7 @@ import java.time.Instant internal actual object TraktPlatformClock { actual fun nowEpochMs(): Long = System.currentTimeMillis() - actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { - Instant.parse(value).toEpochMilli() - }.getOrNull() + actual fun parseIsoDateTimeToEpochMs(value: String): Long? = + runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() + ?: parseTraktIsoDateTimeToEpochMs(value) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt new file mode 100644 index 00000000..79b5bd07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt @@ -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 diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt index 77e6d585..0094c594 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt @@ -1,14 +1,11 @@ package com.nuvio.app.features.trakt import platform.Foundation.NSDate -import platform.Foundation.NSISO8601DateFormatter import platform.Foundation.timeIntervalSince1970 internal actual object TraktPlatformClock { actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() actual fun parseIsoDateTimeToEpochMs(value: String): Long? = - NSISO8601DateFormatter() - .dateFromString(value) - ?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() } + parseTraktIsoDateTimeToEpochMs(value) } From d342959493a34f755456a1c071947b8b6b451107 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 00:28:06 +0530 Subject: [PATCH 04/14] fix(ios): gif causing glithces in liquid glass bar --- .../com/nuvio/app/features/home/HomeScreen.kt | 2 ++ .../components/HomeCollectionRowSection.kt | 8 ++++- .../CollectionCardRemoteImage.ios.kt | 36 +++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 87879839..e549850b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -70,6 +70,7 @@ import org.jetbrains.compose.resources.stringResource @Composable fun HomeScreen( modifier: Modifier = Modifier, + animateCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -560,6 +561,7 @@ fun HomeScreen( collection = collection, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, + animateGifs = animateCollectionGifs, onFolderClick = onFolderClick, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index 2c3121aa..37d3fced 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -37,6 +37,7 @@ fun HomeCollectionRowSection( collection: Collection, modifier: Modifier = Modifier, sectionPadding: Dp? = null, + animateGifs: Boolean = true, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, ) { if (collection.folders.isEmpty()) return @@ -46,6 +47,7 @@ fun HomeCollectionRowSection( collection = collection, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, + animateGifs = animateGifs, onFolderClick = onFolderClick, ) } else { @@ -54,6 +56,7 @@ fun HomeCollectionRowSection( collection = collection, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), + animateGifs = animateGifs, onFolderClick = onFolderClick, ) } @@ -65,6 +68,7 @@ private fun HomeCollectionRowSectionContent( collection: Collection, modifier: Modifier, sectionPadding: Dp, + animateGifs: Boolean, onFolderClick: ((collectionId: String, folderId: String) -> Unit)?, ) { NuvioShelfSection( @@ -77,6 +81,7 @@ private fun HomeCollectionRowSectionContent( ) { folder -> CollectionFolderCard( folder = folder, + animateGifs = animateGifs, onClick = onFolderClick?.let { { it(collection.id, folder.id) } }, ) } @@ -86,6 +91,7 @@ private fun HomeCollectionRowSectionContent( private fun CollectionFolderCard( folder: CollectionFolder, modifier: Modifier = Modifier, + animateGifs: Boolean = true, onClick: (() -> Unit)? = null, ) { val posterCardStyle = rememberPosterCardStyleUiState() @@ -138,7 +144,7 @@ private fun CollectionFolderCard( contentDescription = folder.title, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl), + animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl), ) } !folder.coverEmoji.isNullOrBlank() -> { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt index 11d9fe42..7f1e5c69 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,6 +52,16 @@ private data class ExpandedGifFrames( val tickCentiseconds: Int, ) +private class GifImageViewHolder { + var imageView: UIImageView? = null + + fun clear() { + imageView?.stopAnimating() + imageView?.image = null + imageView = null + } +} + @OptIn(ExperimentalForeignApi::class) @Composable internal actual fun CollectionCardRemoteImage( @@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage( gifImage = loadGifImage(imageUrl) } + val imageViewHolder = remember(imageUrl) { GifImageViewHolder() } + DisposableEffect(imageUrl) { + onDispose { + imageViewHolder.clear() + } + } + UIKitView( modifier = modifier, factory = { @@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage( contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill clipsToBounds = true userInteractionEnabled = false - image = gifImage tag = imageUrl.hashCode().toLong() + imageViewHolder.imageView = this + updateGifImage(gifImage) } }, update = { imageView -> + imageViewHolder.imageView = imageView if (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? { val image = gifImageCache[imageUrl] ?: return null gifImageCacheOrder.remove(imageUrl) @@ -311,4 +341,4 @@ private fun ByteArray.readUnsignedShort(startIndex: Int): Int { return this[startIndex].unsignedInt() or (this[startIndex + 1].unsignedInt() shl 8) } -private fun Byte.unsignedInt(): Int = toInt() and 0xFF \ No newline at end of file +private fun Byte.unsignedInt(): Int = toInt() and 0xFF From 0ce89650c23dbde9c86f19481813d8042245f1d8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 00:28:36 +0530 Subject: [PATCH 05/14] fix: stripping off parameters after manifest.json --- composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 5 +++++ .../com/nuvio/app/features/addons/AddonTransportUrls.kt | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3eebbdac..1d605c3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -512,6 +512,7 @@ private fun MainAppContent( val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled @@ -975,6 +976,7 @@ private fun MainAppContent( val isTabletLayout = maxWidth >= 768.dp val useNativeBottomTabs = liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home @@ -1033,6 +1035,7 @@ private fun MainAppContent( .fillMaxSize() .padding(innerPadding), selectedTab = selectedTab, + animateHomeCollectionGifs = tabsRouteActive, onCatalogClick = onCatalogClick, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) @@ -1952,6 +1955,7 @@ private fun rememberGuardedPopBackStack( private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, + animateHomeCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -1981,6 +1985,7 @@ private fun AppTabHost( AppScreenTab.Home -> { HomeScreen( modifier = Modifier.fillMaxSize(), + animateCollectionGifs = animateHomeCollectionGifs, onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt index 47b852fe..80f913cb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt @@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl( ): String { val encodedId = id.encodeAddonPathSegment() 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" } else { "$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json" } + return resourceUrl + query } @@ -43,4 +47,4 @@ internal fun String.encodeAddonPathSegment(): String = } } -private const val ADDON_URL_HEX = "0123456789ABCDEF" \ No newline at end of file +private const val ADDON_URL_HEX = "0123456789ABCDEF" From c16711ebb80411463fce3a0300a9495bbeb3b9a8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 00:46:55 +0530 Subject: [PATCH 06/14] feat: adding seperate preference key for collections --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...PlatformLocalAccountDataCleaner.android.kt | 1 + ...CollectionMobileSettingsStorage.android.kt | 26 +++ .../core/storage/LocalAccountDataCleaner.kt | 2 + .../app/core/sync/ProfileSettingsSync.kt | 10 ++ .../collection/CollectionEditorRepository.kt | 6 +- .../collection/CollectionEditorScreen.kt | 4 +- .../CollectionMobileSettingsRepository.kt | 155 ++++++++++++++++++ .../CollectionMobileSettingsStorage.kt | 6 + .../features/collection/CollectionModels.kt | 3 + .../collection/CollectionRepository.kt | 19 ++- .../components/HomeCollectionRowSection.kt | 4 +- .../features/profiles/ProfileRepository.kt | 2 + .../CollectionSourceSerializationTest.kt | 70 ++++++++ .../PlatformLocalAccountDataCleaner.ios.kt | 1 + .../CollectionMobileSettingsStorage.ios.kt | 15 ++ 16 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index e899b044..339340ab 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -13,6 +13,7 @@ import com.nuvio.app.core.auth.AuthStorage import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner 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.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader @@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() { WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) + CollectionMobileSettingsStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext) DownloadsStorage.initialize(applicationContext) DownloadsPlatformDownloader.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 9edf1191..b7243288 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_episode_release_notifications", "nuvio_episode_release_notifications_platform", "nuvio_watch_progress", + "nuvio_collection_mobile_settings", "nuvio_collections", "nuvio_plugins", ) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt new file mode 100644 index 00000000..caaba36c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 603fce83..8892f6e6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -3,6 +3,7 @@ package com.nuvio.app.core.storage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository 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.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository @@ -44,6 +45,7 @@ internal object LocalAccountDataCleaner { WatchedRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState() + CollectionMobileSettingsRepository.clearLocalState() CollectionRepository.clearLocalState() ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 9dd7a999..58df719e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState 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.MetaScreenSettingsRepository import com.nuvio.app.features.mdblist.MdbListMetadataService @@ -158,6 +160,7 @@ object ProfileSettingsSync { TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, + CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, TraktSettingsRepository.uiState.map { "trakt_settings" }, TraktCommentsSettings.enabled.map { "trakt_comments" }, @@ -202,6 +205,7 @@ object ProfileSettingsSync { tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), + collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), @@ -232,6 +236,9 @@ object ProfileSettingsSync { MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload) MetaScreenSettingsRepository.onProfileChanged() + CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload) + CollectionMobileSettingsRepository.onProfileChanged() + ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesRepository.onProfileChanged() @@ -251,6 +258,7 @@ object ProfileSettingsSync { TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() + CollectionMobileSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() @@ -272,6 +280,7 @@ object ProfileSettingsSync { "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", + "collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}", "trakt_settings=${TraktSettingsRepository.uiState.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}", @@ -293,6 +302,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @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("trakt_settings_payload") val traktSettingsPayload: String = "", @SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index 0a31a9d7..70b5204f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -195,10 +195,10 @@ object CollectionEditorRepository { ) } - fun updateFolderFocusGifEnabled(enabled: Boolean) { + fun updateFolderMobileFocusGifEnabled(enabled: Boolean) { val folder = _uiState.value.editingFolder ?: return _uiState.value = _uiState.value.copy( - editingFolder = folder.copy(focusGifEnabled = enabled), + editingFolder = folder.copy(mobileFocusGifEnabled = enabled), ) } @@ -808,6 +808,8 @@ object CollectionEditorRepository { folders = state.folders, ) + CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders) + if (state.isNew) { CollectionRepository.addCollection(collection) } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 1114ac1b..7219395a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -702,8 +702,8 @@ private fun FolderEditorPage( FolderEditorToggleRow( title = stringResource(Res.string.collections_editor_show_gif_when_configured), subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc), - checked = folder.focusGifEnabled, - onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) }, + checked = folder.mobileFocusGifEnabled, + onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) }, ) FolderEditorToggleRow( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt new file mode 100644 index 00000000..c122ae63 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt @@ -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 = emptyMap(), +) + +object CollectionMobileSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState()) + val uiState: StateFlow = _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): List { + 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) { + 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(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 { 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 = emptyList(), +) + +@Serializable +private data class StoredFolderGifOverride( + @SerialName("collection_id") val collectionId: String, + @SerialName("folder_id") val folderId: String, + val enabled: Boolean = true, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt new file mode 100644 index 00000000..58ac9020 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.collection + +internal expect object CollectionMobileSettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index ba9080d6..5ec3b606 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.nuvio.app.features.home.PosterShape import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient enum class FolderViewMode { TABBED_GRID, @@ -168,6 +169,8 @@ data class CollectionFolder( val coverImageUrl: String? = null, val focusGifUrl: String? = null, val focusGifEnabled: Boolean = true, + @Transient + val mobileFocusGifEnabled: Boolean = true, val coverEmoji: String? = null, val tileShape: String = "poster", val hideTitle: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 39916184..270e9781 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -52,7 +52,8 @@ object CollectionRepository { runCatching { val parsed = json.parseToJsonElement(payload) rawCollectionsJson = parsed - _collections.value = json.decodeFromString>(payload) + val decoded = json.decodeFromString>(payload) + _collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded) }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } } @@ -75,14 +76,15 @@ object CollectionRepository { fun addCollection(collection: Collection) { ensureLoaded() - _collections.value = _collections.value + collection + _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection) persist() } fun updateCollection(collection: Collection) { ensureLoaded() + val decorated = CollectionMobileSettingsRepository.applyToCollection(collection) _collections.value = _collections.value.map { - if (it.id == collection.id) collection else it + if (it.id == collection.id) decorated else it } persist() } @@ -95,7 +97,7 @@ object CollectionRepository { fun setCollections(collections: List) { ensureLoaded() - _collections.value = collections + _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) persist() } @@ -127,7 +129,7 @@ object CollectionRepository { return runCatching { rawCollectionsJson = json.parseToJsonElement(jsonString) val imported = json.decodeFromString>(jsonString) - _collections.value = imported + _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported) persist() imported } @@ -262,10 +264,15 @@ object CollectionRepository { internal fun applyFromRemote(collections: List, rawJson: JsonElement) { rawCollectionsJson = rawJson - _collections.value = collections + _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) persist(sync = false) } + internal fun onMobileSettingsChanged() { + if (!hasLoaded) return + _collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value) + } + private fun ensureLoaded() { if (!hasLoaded) initialize() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index 37d3fced..dd053375 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -186,7 +186,7 @@ private fun CollectionFolderCard( } private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? { - return if (folder.focusGifEnabled) { + return if (folder.mobileFocusGifEnabled) { firstNonBlank(folder.focusGifUrl, folder.coverImageUrl) } else { firstNonBlank(folder.coverImageUrl) @@ -202,5 +202,5 @@ private fun isAnimatedCollectionFolderImage( imageUrl: String, ): Boolean { val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false - return folder.focusGifEnabled && imageUrl == gifUrl + return folder.mobileFocusGifEnabled && imageUrl == gifUrl } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 0cb6cc27..5760e73e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.network.SupabaseProvider 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.downloads.DownloadsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository @@ -156,6 +157,7 @@ object ProfileRepository { TraktAuthRepository.onProfileChanged() SearchHistoryRepository.onProfileChanged() CollectionRepository.onProfileChanged() + CollectionMobileSettingsRepository.onProfileChanged() DownloadsRepository.onProfileChanged() } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt index 66f227dd..5f83cd99 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt @@ -3,8 +3,13 @@ package com.nuvio.app.features.collection import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString 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.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -178,4 +183,69 @@ class CollectionSourceSerializationTest { assertTrue(merged.contains(""""customField":"keep-me"""")) 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>(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>(payload).single().folders.single() + + assertFalse(folder.focusGifEnabled) + assertTrue(folder.mobileFocusGifEnabled) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 553140ee..b3e60b1d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner { "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "collection_mobile_settings_payload", "collections_payload", ) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt new file mode 100644 index 00000000..e214807d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt @@ -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)) + } +} From a3c6f8603ef48838613d3b18691cd4beb999be13 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 00:50:02 +0530 Subject: [PATCH 07/14] fix: mobile collection changes overriding tv follow home layout setting --- .../com/nuvio/app/features/collection/CollectionModels.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 5ec3b606..31962922 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -14,7 +14,7 @@ enum class FolderViewMode { companion object { fun fromString(value: String): FolderViewMode = 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(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID else -> TABBED_GRID From 4ca66bc38514d249e4519ecd5e4f73d61399e4ee Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 00:59:15 +0530 Subject: [PATCH 08/14] ref: remove account deletion button --- .../features/settings/AccountSettingsPage.kt | 53 ------------------- .../app/features/settings/SettingsSearch.kt | 10 ---- 2 files changed, 63 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index 4e17b58a..e80c0822 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.Text import androidx.compose.runtime.Composable @@ -20,24 +17,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository 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.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res 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.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_not_signed_in import nuvio.composeapp.generated.resources.settings_account_sign_out @@ -62,7 +52,6 @@ private fun AccountSettingsBody( ) { val authState by AuthRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - var showDeleteConfirm by remember { mutableStateOf(false) } var showSignOutConfirm by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -131,35 +120,6 @@ private fun AccountSettingsBody( text = stringResource(Res.string.settings_account_sign_out), 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( @@ -174,17 +134,4 @@ private fun AccountSettingsBody( }, 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 }, - ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 1a8a2e56..bdacf0de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -279,16 +279,6 @@ internal fun settingsSearchEntries( category = accountCategory, icon = Icons.Rounded.AccountCircle, ) - addRow( - page = SettingsPage.Account, - key = "account-delete", - title = stringResource(Res.string.settings_account_delete_account), - description = stringResource(Res.string.settings_account_delete_account_description), - pageLabel = accountPage, - section = accountPage, - category = accountCategory, - icon = Icons.Rounded.AccountCircle, - ) addRow( page = SettingsPage.Appearance, From a6f8041c9e31cd831009445f853d3b4a2f916f2d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 01:02:13 +0530 Subject: [PATCH 09/14] fix: volume/brightness gesture triggering when hold to speed is active Fixes #992 --- .../kotlin/com/nuvio/app/features/player/PlayerScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fc24fba4..785ecb6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1449,12 +1449,15 @@ fun PlayerScreen( totalDy += delta.y if (gestureMode == null) { + val holdToSpeedActive = isHoldToSpeedGestureActiveState.value val horizontalDominant = - !isHoldToSpeedGestureActiveState.value && + !holdToSpeedActive && abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy) val verticalDominant = - abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) + !holdToSpeedActive && + abs(totalDy) > viewConfiguration.touchSlop && + abs(totalDy) > abs(totalDx) gestureMode = when { horizontalDominant -> { From 951cb8f20137a86f0c976e1de4b5e519bb2f41e1 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 01:06:22 +0530 Subject: [PATCH 10/14] fix: search screen sticky header being clickable fixes #969 --- .../com/nuvio/app/features/search/SearchScreen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index c25a67fc..9ecd07d8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -220,7 +221,14 @@ fun SearchScreen( androidx.compose.foundation.layout.Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent() + } + } + }, ) { NuvioScreenHeader( title = headerTitle, From daedf05ea516a1661550c9032d3deac384a4507b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 01:18:21 +0530 Subject: [PATCH 11/14] feat: support for trakt library items removal from library screen fixes #962 --- .../composeResources/values/strings.xml | 1 + .../app/features/library/LibraryRepository.kt | 16 +++++ .../app/features/library/LibraryScreen.kt | 59 +++++++++++++++---- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 78461786..55d0bbf0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1114,6 +1114,7 @@ Download failed Paused %1$s Remove + Remove %1$s from %2$s? Remove %1$s from your library? Remove from Library? Movie diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index c93d5caa..46c2acdc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -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() { syncScope.launch { runCatching { @@ -417,6 +425,14 @@ internal fun libraryMembershipWithLocal( putAll(traktMembership) } +internal fun libraryMembershipWithRemovedList( + currentMembership: Map, + listKey: String, +): Map = + currentMembership.toMutableMap().apply { + this[listKey] = false + } + private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( id = contentId, type = contentType, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index efe6ded9..4a8f78c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader 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.NuvioShelfSection 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 kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +private data class LibraryRemovalTarget( + val item: LibraryItem, + val listKey: String? = null, + val listTitle: String? = null, +) + @Composable fun LibraryScreen( modifier: Modifier = Modifier, @@ -46,7 +54,7 @@ fun LibraryScreen( LibraryRepository.uiState }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - var pendingRemovalItem by remember { mutableStateOf(null) } + var pendingRemovalTarget by remember { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT @@ -165,9 +173,15 @@ fun LibraryScreen( sections = uiState.sections, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = { item -> - if (!isTraktSource) { - pendingRemovalItem = item + onPosterLongClick = { item, section -> + pendingRemovalTarget = if (isTraktSource) { + LibraryRemovalTarget( + item = item, + listKey = section.type, + listTitle = section.displayTitle, + ) + } else { + LibraryRemovalTarget(item = item) } }, ) @@ -177,17 +191,38 @@ fun LibraryScreen( NuvioStatusModal( title = stringResource(Res.string.library_remove_title), - message = pendingRemovalItem?.let { - stringResource(Res.string.library_remove_message, it.name) + message = pendingRemovalTarget?.let { target -> + 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(), - isVisible = pendingRemovalItem != null, + isVisible = pendingRemovalTarget != null, confirmText = stringResource(Res.string.library_remove_confirm), dismissText = stringResource(Res.string.action_cancel), onConfirm = { - pendingRemovalItem?.id?.let(LibraryRepository::remove) - pendingRemovalItem = null + val target = pendingRemovalTarget + 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 +230,7 @@ private fun LazyListScope.librarySections( sections: List, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, - onPosterLongClick: (LibraryItem) -> Unit, + onPosterLongClick: (LibraryItem, LibrarySection) -> Unit, ) { items( items = sections, @@ -218,7 +253,7 @@ private fun LazyListScope.librarySections( HomePosterCard( item = item.toMetaPreview(), onClick = onPosterClick?.let { { it(item) } }, - onLongClick = { onPosterLongClick(item) }, + onLongClick = { onPosterLongClick(item, section) }, ) } } From 1a0738551fdefc4ef3e7b50b7f79d1ea3eee7ae0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 01:20:36 +0530 Subject: [PATCH 12/14] ref: amoled to keep default surface theming --- .../src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt index 38c88914..d86a1a81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt @@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false onSecondary = palette.onSecondaryVariant, background = if (amoled) Color.Black else palette.background, onBackground = Color(0xFFF5F7F8), - surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated, + surface = palette.backgroundElevated, onSurface = Color(0xFFF5F7F8), - surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard, + surfaceVariant = palette.backgroundCard, onSurfaceVariant = Color(0xFF969CA3), outline = Color(0xFF252A2A), error = Color(0xFFE36A8A), From cf1bd1613b657b6d687e54aa5cb1aeae5f72e173 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 01:27:40 +0530 Subject: [PATCH 13/14] ref: adjust the search screen empty state ui --- .../nuvio/app/features/search/SearchScreen.kt | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 9ecd07d8..bad6cc11 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -285,53 +285,61 @@ fun SearchScreen( onPosterLongClick = onPosterLongClick, ) } else { - when { - uiState.isLoading && uiState.sections.isEmpty() -> { - items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + val normalizedQuery = query.trim() + val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery + when { + isWaitingForSearch -> { + items(2) { + HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + } } - } - uiState.sections.isEmpty() -> { - item { - SearchEmptyStateCard( - reason = uiState.emptyStateReason, - errorMessage = uiState.errorMessage, - networkCondition = networkStatusUiState.condition, - onRetry = { - val normalizedQuery = query.trim() - if (normalizedQuery.isNotBlank()) { - NetworkStatusRepository.requestRefresh(force = true) - SearchRepository.search( - query = normalizedQuery, - addons = addonsUiState.addons, - ) - } - }, - ) + uiState.isLoading && uiState.sections.isEmpty() -> { + items(2) { + HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + } } - } - else -> { - items( - items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key }, - key = { section -> section.lazyKey }, - ) { keyedSection -> - val section = keyedSection.value - HomeCatalogRowSection( - section = section, - modifier = Modifier.padding(bottom = 12.dp), - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onPosterClick, - onPosterLongClick = onPosterLongClick, - ) + uiState.sections.isEmpty() -> { + item { + SearchEmptyStateCard( + reason = uiState.emptyStateReason, + errorMessage = uiState.errorMessage, + networkCondition = networkStatusUiState.condition, + onRetry = { + if (normalizedQuery.isNotBlank()) { + NetworkStatusRepository.requestRefresh(force = true) + SearchRepository.search( + query = normalizedQuery, + addons = addonsUiState.addons, + ) + } + }, + modifier = Modifier.padding(horizontal = homeSectionPadding), + ) + } + } + + else -> { + items( + items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key }, + key = { section -> section.lazyKey }, + ) { keyedSection -> + val section = keyedSection.value + HomeCatalogRowSection( + section = section, + modifier = Modifier.padding(bottom = 12.dp), + watchedKeys = watchedUiState.watchedKeys, + onPosterClick = onPosterClick, + onPosterLongClick = onPosterLongClick, + ) + } } } } } } } -} private fun discoverColumnCountForWidth(screenWidth: Dp): Int = when { From 96d0b0703ea34b8bde4da5110a786f4d78c28162 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 9 May 2026 01:31:31 +0530 Subject: [PATCH 14/14] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 7e78cee8..837f01a4 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=55 -MARKETING_VERSION=0.1.0 +CURRENT_PROJECT_VERSION=56 +MARKETING_VERSION=0.1.17