mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
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:
parent
96d0b0703e
commit
1fa8456979
7 changed files with 232 additions and 2 deletions
|
|
@ -517,6 +517,12 @@
|
|||
<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_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_on_launch">ON LAUNCH</string>
|
||||
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
|
||||
|
|
|
|||
|
|
@ -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<WatchProgressEntry>,
|
||||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
||||
todayIsoDate: String = "",
|
||||
): List<ContinueWatchingItem> {
|
||||
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<String>()
|
||||
val deduplicated = candidates
|
||||
.sortedWith(
|
||||
compareByDescending<HomeContinueWatchingCandidate> { 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<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)
|
||||
|
||||
return sortedReleased + sortedUnreleased
|
||||
}
|
||||
|
||||
private data class CompletedSeriesCandidate(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ fun ContinueWatchingSettingsScreen(
|
|||
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
sortMode = continueWatchingPreferencesUiState.sortMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ private data class StoredContinueWatchingPreferences(
|
|||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = 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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
|
||||
)
|
||||
|
||||
internal fun nextUpDismissKey(
|
||||
|
|
|
|||
Loading…
Reference in a new issue