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(