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
This commit is contained in:
Guilherme Lima Pereira 2026-05-09 17:31:29 -03:00
parent 96d0b0703e
commit 1fa8456979
7 changed files with 232 additions and 2 deletions

View file

@ -517,6 +517,12 @@
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string> <string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string> <string name="settings_continue_watching_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string> <string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</string>
<string name="settings_continue_watching_section_sort_order">SORT ORDER</string>
<string name="settings_continue_watching_sort_mode_title">Sort Order</string>
<string name="settings_continue_watching_sort_mode_default">Default</string>
<string name="settings_continue_watching_sort_mode_default_desc">Sort all items by recency</string>
<string name="settings_continue_watching_sort_mode_streaming">Streaming Style</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string> <string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string> <string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string> <string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>

View file

@ -42,6 +42,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressClock
@ -247,11 +248,14 @@ fun HomeScreen(
visibleContinueWatchingEntries, visibleContinueWatchingEntries,
cachedInProgressItems, cachedInProgressItems,
effectivNextUpItems, effectivNextUpItems,
continueWatchingPreferences.sortMode,
) { ) {
buildHomeContinueWatchingItems( buildHomeContinueWatchingItems(
visibleEntries = visibleContinueWatchingEntries, visibleEntries = visibleContinueWatchingEntries,
cachedInProgressByVideoId = cachedInProgressItems, cachedInProgressByVideoId = cachedInProgressItems,
nextUpItemsBySeries = effectivNextUpItems, nextUpItemsBySeries = effectivNextUpItems,
sortMode = continueWatchingPreferences.sortMode,
todayIsoDate = CurrentDateProvider.todayIsoDate(),
) )
} }
val availableManifests = remember(addonsUiState.addons) { val availableManifests = remember(addonsUiState.addons) {
@ -633,6 +637,8 @@ internal fun buildHomeContinueWatchingItems(
visibleEntries: List<WatchProgressEntry>, visibleEntries: List<WatchProgressEntry>,
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(), cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>, nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "",
): List<ContinueWatchingItem> { ): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries val inProgressSeriesIds = visibleEntries
.asSequence() .asSequence()
@ -641,7 +647,7 @@ internal fun buildHomeContinueWatchingItems(
.filter(String::isNotBlank) .filter(String::isNotBlank)
.toSet() .toSet()
return buildList { val candidates = buildList {
addAll( addAll(
visibleEntries.map { entry -> visibleEntries.map { entry ->
val liveItem = entry.toContinueWatchingItem() val liveItem = entry.toContinueWatchingItem()
@ -663,13 +669,62 @@ internal fun buildHomeContinueWatchingItems(
}, },
) )
} }
// Deduplicate by series/content id first (order-stable)
val seen = mutableSetOf<String>()
val deduplicated = candidates
.sortedWith( .sortedWith(
compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs } compareByDescending<HomeContinueWatchingCandidate> { it.lastUpdatedEpochMs }
.thenByDescending { it.isProgressEntry }, .thenByDescending { it.isProgressEntry },
) )
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } } .filter { candidate ->
val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId }
seen.add(key)
}
return when (sortMode) {
ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item)
ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate)
}
}
private fun applyStreamingStyleSort(
candidates: List<HomeContinueWatchingCandidate>,
todayIsoDate: String,
): List<ContinueWatchingItem> {
val (released, unreleased) = candidates.partition { candidate ->
val item = candidate.item
if (!item.isNextUp) {
true // in-progress items are always "released"
} else {
val itemReleased = item.released
if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) {
true // no date info → treat as released
} else {
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased)
}
}
}
// Released: most recently watched first (already sorted by dedup pass)
val sortedReleased = released.map(HomeContinueWatchingCandidate::item)
// Unaired: soonest air date first; unknown dates go to the end
val sortedUnreleased = unreleased
.sortedWith { a, b ->
val dateA = a.item.released?.takeIf { it.isNotBlank() }
val dateB = b.item.released?.takeIf { it.isNotBlank() }
when {
dateA == null && dateB == null -> 0
dateA == null -> 1
dateB == null -> -1
else -> dateA.compareTo(dateB)
}
}
.map(HomeContinueWatchingCandidate::item) .map(HomeContinueWatchingCandidate::item)
return sortedReleased + sortedUnreleased
} }
private data class CompletedSeriesCandidate( private data class CompletedSeriesCandidate(

View file

@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -25,6 +34,7 @@ import androidx.compose.ui.unit.dp
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
@ -34,10 +44,16 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_show_unai
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
import nuvio.composeapp.generated.resources.settings_continue_watching_section_sort_order
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility
import nuvio.composeapp.generated.resources.settings_continue_watching_show_description import nuvio.composeapp.generated.resources.settings_continue_watching_show_description
import nuvio.composeapp.generated.resources.settings_continue_watching_show_title import nuvio.composeapp.generated.resources.settings_continue_watching_show_title
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
@ -58,6 +74,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
showUnairedNextUp: Boolean, showUnairedNextUp: Boolean,
blurNextUp: Boolean, blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean, showResumePromptOnLaunch: Boolean,
sortMode: ContinueWatchingSortMode,
) { ) {
item { item {
SettingsSection( SettingsSection(
@ -145,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent(
} }
} }
} }
item {
var showSortModeSheet by remember { mutableStateOf(false) }
SettingsSection(
title = stringResource(Res.string.settings_continue_watching_section_sort_order),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
val currentModeLabel = stringResource(
when (sortMode) {
ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default
ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming
}
)
SettingsNavigationRow(
title = stringResource(Res.string.settings_continue_watching_sort_mode_title),
description = currentModeLabel,
isTablet = isTablet,
onClick = { showSortModeSheet = true },
)
}
}
if (showSortModeSheet) {
ContinueWatchingSortModeDialog(
currentMode = sortMode,
onModeSelected = { mode ->
ContinueWatchingPreferencesRepository.setSortMode(mode)
showSortModeSheet = false
},
onDismiss = { showSortModeSheet = false },
)
}
}
} }
@Composable @Composable
@ -250,3 +300,101 @@ private val ContinueWatchingSectionStyle.descriptionRes: StringResource
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContinueWatchingSortModeDialog(
currentMode: ContinueWatchingSortMode,
onModeSelected: (ContinueWatchingSortMode) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(
Triple(
ContinueWatchingSortMode.DEFAULT,
Res.string.settings_continue_watching_sort_mode_default,
Res.string.settings_continue_watching_sort_mode_default_desc,
),
Triple(
ContinueWatchingSortMode.STREAMING_STYLE,
Res.string.settings_continue_watching_sort_mode_streaming,
Res.string.settings_continue_watching_sort_mode_streaming_desc,
),
)
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_continue_watching_sort_mode_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { (mode, titleRes, descriptionRes) ->
val isSelected = mode == currentMode
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onModeSelected(mode) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}

View file

@ -135,6 +135,7 @@ fun ContinueWatchingSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
) )
} }
} }

View file

@ -490,6 +490,7 @@ private fun MobileSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = false, isTablet = false,
@ -842,6 +843,7 @@ private fun TabletSettingsScreen(
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp, blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
sortMode = continueWatchingPreferencesUiState.sortMode,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
isTablet = true, isTablet = true,

View file

@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences(
val blurNextUp: Boolean = false, val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(), val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
@SerialName("sort_mode")
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
) )
object ContinueWatchingPreferencesRepository { object ContinueWatchingPreferencesRepository {
@ -97,6 +99,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = stored.blurNextUp, blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys, dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch, showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
sortMode = stored.sortMode,
) )
} else { } else {
ContinueWatchingPreferencesUiState() ContinueWatchingPreferencesUiState()
@ -155,6 +158,13 @@ object ContinueWatchingPreferencesRepository {
persist() persist()
} }
fun setSortMode(mode: ContinueWatchingSortMode) {
ensureLoaded()
if (_uiState.value.sortMode == mode) return
_uiState.value = _uiState.value.copy(sortMode = mode)
persist()
}
fun removeDismissedNextUpKeysForContent(contentId: String) { fun removeDismissedNextUpKeysForContent(contentId: String) {
ensureLoaded() ensureLoaded()
val normalizedContentId = contentId.trim() val normalizedContentId = contentId.trim()
@ -178,6 +188,7 @@ object ContinueWatchingPreferencesRepository {
blurNextUp = _uiState.value.blurNextUp, blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
sortMode = _uiState.value.sortMode,
), ),
), ),
) )

View file

@ -17,6 +17,12 @@ enum class ContinueWatchingSectionStyle {
Poster, Poster,
} }
@Serializable
enum class ContinueWatchingSortMode {
DEFAULT,
STREAMING_STYLE,
}
@Serializable @Serializable
data class WatchProgressEntry( data class WatchProgressEntry(
val contentType: String, val contentType: String,
@ -175,6 +181,7 @@ data class ContinueWatchingPreferencesUiState(
val blurNextUp: Boolean = false, val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(), val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
) )
internal fun nextUpDismissKey( internal fun nextUpDismissKey(