From 1fa84569790253c571a392712c5dc37d56e8acdf Mon Sep 17 00:00:00 2001 From: Guilherme Lima Pereira Date: Sat, 9 May 2026 17:31:29 -0300 Subject: [PATCH] feat(settings): add Continue Watching sort mode setting Port of NuvioMedia/NuvioTV#1704. Adds a Sort Order setting to the Continue Watching section with two modes: - Default: sort all items by most recently watched - Streaming Style: released/in-progress items first (by recency), unaired next-up items pushed to the end sorted by ascending air date --- .../composeResources/values/strings.xml | 6 + .../com/nuvio/app/features/home/HomeScreen.kt | 59 ++++++- .../settings/ContinueWatchingSettingsPage.kt | 148 ++++++++++++++++++ .../settings/SettingsFullScreenPages.kt | 1 + .../app/features/settings/SettingsScreen.kt | 2 + .../ContinueWatchingPreferencesRepository.kt | 11 ++ .../watchprogress/WatchProgressModels.kt | 7 + 7 files changed, 232 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 55d0bbf0..f91bd40b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -517,6 +517,12 @@ Blur Unwatched in Continue Watching Include upcoming episodes in Continue Watching before they air. Show Unaired Next Up Episodes + SORT ORDER + Sort Order + Default + Sort all items by recency + Streaming Style + Released items first, upcoming at the end Poster Card Style ON LAUNCH UP NEXT BEHAVIOR 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 e549850b..287875a2 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 @@ -42,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.WatchProgressClock @@ -247,11 +248,14 @@ fun HomeScreen( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, + continueWatchingPreferences.sortMode, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, cachedInProgressByVideoId = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, + sortMode = continueWatchingPreferences.sortMode, + todayIsoDate = CurrentDateProvider.todayIsoDate(), ) } val availableManifests = remember(addonsUiState.addons) { @@ -633,6 +637,8 @@ internal fun buildHomeContinueWatchingItems( visibleEntries: List, cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, + sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, + todayIsoDate: String = "", ): List { val inProgressSeriesIds = visibleEntries .asSequence() @@ -641,7 +647,7 @@ internal fun buildHomeContinueWatchingItems( .filter(String::isNotBlank) .toSet() - return buildList { + val candidates = buildList { addAll( visibleEntries.map { entry -> val liveItem = entry.toContinueWatchingItem() @@ -663,13 +669,62 @@ internal fun buildHomeContinueWatchingItems( }, ) } + + // Deduplicate by series/content id first (order-stable) + val seen = mutableSetOf() + val deduplicated = candidates .sortedWith( compareByDescending { it.lastUpdatedEpochMs } .thenByDescending { it.isProgressEntry }, ) .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } - .distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } } + .filter { candidate -> + val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId } + seen.add(key) + } + + return when (sortMode) { + ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item) + ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate) + } +} + +private fun applyStreamingStyleSort( + candidates: List, + todayIsoDate: String, +): List { + val (released, unreleased) = candidates.partition { candidate -> + val item = candidate.item + if (!item.isNextUp) { + true // in-progress items are always "released" + } else { + val itemReleased = item.released + if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) { + true // no date info → treat as released + } else { + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased) + } + } + } + + // Released: most recently watched first (already sorted by dedup pass) + val sortedReleased = released.map(HomeContinueWatchingCandidate::item) + + // Unaired: soonest air date first; unknown dates go to the end + val sortedUnreleased = unreleased + .sortedWith { a, b -> + val dateA = a.item.released?.takeIf { it.isNotBlank() } + val dateB = b.item.released?.takeIf { it.isNotBlank() } + when { + dateA == null && dateB == null -> 0 + dateA == null -> 1 + dateB == null -> -1 + else -> dateA.compareTo(dateB) + } + } .map(HomeContinueWatchingCandidate::item) + + return sortedReleased + sortedUnreleased } private data class CompletedSeriesCandidate( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index c3d81354..f0483992 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -25,6 +34,7 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.features.home.components.ContinueWatchingStylePreview import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title @@ -34,10 +44,16 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_show_unai import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch +import nuvio.composeapp.generated.resources.settings_continue_watching_section_sort_order import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility import nuvio.composeapp.generated.resources.settings_continue_watching_show_description import nuvio.composeapp.generated.resources.settings_continue_watching_show_title +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide @@ -58,6 +74,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( showUnairedNextUp: Boolean, blurNextUp: Boolean, showResumePromptOnLaunch: Boolean, + sortMode: ContinueWatchingSortMode, ) { item { SettingsSection( @@ -145,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent( } } } + item { + var showSortModeSheet by remember { mutableStateOf(false) } + SettingsSection( + title = stringResource(Res.string.settings_continue_watching_section_sort_order), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + val currentModeLabel = stringResource( + when (sortMode) { + ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default + ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming + } + ) + SettingsNavigationRow( + title = stringResource(Res.string.settings_continue_watching_sort_mode_title), + description = currentModeLabel, + isTablet = isTablet, + onClick = { showSortModeSheet = true }, + ) + } + } + + if (showSortModeSheet) { + ContinueWatchingSortModeDialog( + currentMode = sortMode, + onModeSelected = { mode -> + ContinueWatchingPreferencesRepository.setSortMode(mode) + showSortModeSheet = false + }, + onDismiss = { showSortModeSheet = false }, + ) + } + } } @Composable @@ -250,3 +300,101 @@ private val ContinueWatchingSectionStyle.descriptionRes: StringResource ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContinueWatchingSortModeDialog( + currentMode: ContinueWatchingSortMode, + onModeSelected: (ContinueWatchingSortMode) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf( + Triple( + ContinueWatchingSortMode.DEFAULT, + Res.string.settings_continue_watching_sort_mode_default, + Res.string.settings_continue_watching_sort_mode_default_desc, + ), + Triple( + ContinueWatchingSortMode.STREAMING_STYLE, + Res.string.settings_continue_watching_sort_mode_streaming, + Res.string.settings_continue_watching_sort_mode_streaming_desc, + ), + ) + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_continue_watching_sort_mode_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { (mode, titleRes, descriptionRes) -> + val isSelected = mode == currentMode + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onModeSelected(mode) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index 143ef517..cbb6bfa4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -135,6 +135,7 @@ fun ContinueWatchingSettingsScreen( showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + sortMode = continueWatchingPreferencesUiState.sortMode, ) } } 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 45a52feb..c75ae332 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 @@ -490,6 +490,7 @@ private fun MobileSettingsScreen( showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + sortMode = continueWatchingPreferencesUiState.sortMode, ) SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( isTablet = false, @@ -842,6 +843,7 @@ private fun TabletSettingsScreen( showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + sortMode = continueWatchingPreferencesUiState.sortMode, ) SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( isTablet = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt index 9845b680..93704067 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt @@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences( val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, + @SerialName("sort_mode") + val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, ) object ContinueWatchingPreferencesRepository { @@ -97,6 +99,7 @@ object ContinueWatchingPreferencesRepository { blurNextUp = stored.blurNextUp, dismissedNextUpKeys = stored.dismissedNextUpKeys, showResumePromptOnLaunch = stored.showResumePromptOnLaunch, + sortMode = stored.sortMode, ) } else { ContinueWatchingPreferencesUiState() @@ -155,6 +158,13 @@ object ContinueWatchingPreferencesRepository { persist() } + fun setSortMode(mode: ContinueWatchingSortMode) { + ensureLoaded() + if (_uiState.value.sortMode == mode) return + _uiState.value = _uiState.value.copy(sortMode = mode) + persist() + } + fun removeDismissedNextUpKeysForContent(contentId: String) { ensureLoaded() val normalizedContentId = contentId.trim() @@ -178,6 +188,7 @@ object ContinueWatchingPreferencesRepository { blurNextUp = _uiState.value.blurNextUp, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, + sortMode = _uiState.value.sortMode, ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 1c27213d..0485986b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -17,6 +17,12 @@ enum class ContinueWatchingSectionStyle { Poster, } +@Serializable +enum class ContinueWatchingSortMode { + DEFAULT, + STREAMING_STYLE, +} + @Serializable data class WatchProgressEntry( val contentType: String, @@ -175,6 +181,7 @@ data class ContinueWatchingPreferencesUiState( val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, + val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, ) internal fun nextUpDismissKey(