feat: implement 'Up Next from furthest episode' preference in Continue Watching settings and update related logic across components

This commit is contained in:
tapframe 2026-03-30 22:12:08 +05:30
parent 515f3de17f
commit 97f0eeb5da
12 changed files with 224 additions and 47 deletions

View file

@ -55,6 +55,7 @@ import com.nuvio.app.features.watched.releasedEpisodesForSeason
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
@ -148,11 +149,13 @@ fun MetaDetailsScreen(
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
?.takeUnless { it.isCompleted }
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate) {
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
meta.seriesPrimaryAction(
entries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = cwPrefs.upNextFromFurthestEpisode,
)
}
val seriesPauseDescription = remember(seriesAction, meta.id, meta.videos) {

View file

@ -50,7 +50,7 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
return sortedEpisodes
val candidates = sortedEpisodes
.dropWhile { episode ->
buildPlaybackVideoId(
content = WatchingContentRef(type = type, id = id),
@ -60,9 +60,10 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
) != watchedVideoId
}
.drop(1)
.firstOrNull { episode ->
.filter { episode ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
}
return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 }
}
internal data class SeriesPrimaryAction(
@ -79,6 +80,7 @@ internal fun MetaDetails.seriesPrimaryAction(
entries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
): SeriesPrimaryAction? =
decideSeriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
@ -86,6 +88,7 @@ internal fun MetaDetails.seriesPrimaryAction(
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
)?.toLegacySeriesPrimaryAction()
internal fun MetaVideo.playLabel(): String =

View file

@ -56,10 +56,11 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val latestCompletedBySeries = remember(watchProgressUiState.entries, watchedUiState.items) {
val latestCompletedBySeries = remember(watchProgressUiState.entries, watchedUiState.items, continueWatchingPreferences.upNextFromFurthestEpisode) {
WatchingState.latestCompletedBySeries(
progressEntries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
)
}
val completedSeriesCandidates = remember(latestCompletedBySeries) {

View file

@ -29,6 +29,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isTablet: Boolean,
isVisible: Boolean,
style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean,
) {
item {
SettingsSection(
@ -58,6 +59,22 @@ internal fun LazyListScope.continueWatchingSettingsContent(
)
}
}
item {
SettingsSection(
title = "UP NEXT BEHAVIOR",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Up Next from furthest episode",
description = "When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode — useful if you rewatch earlier episodes.",
checked = upNextFromFurthestEpisode,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
)
}
}
}
}
@Composable

View file

@ -74,6 +74,7 @@ fun ContinueWatchingSettingsScreen(
isTablet = false,
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
)
}
}

View file

@ -49,6 +49,7 @@ object WatchingState {
fun latestCompletedBySeries(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
preferFurthestEpisode: Boolean = true,
): Map<WatchingContentRef, WatchingCompletedEpisode> {
val contentRefs = buildSet {
progressEntries.forEach { entry ->
@ -65,6 +66,7 @@ object WatchingState {
content = content,
progressRecords = progressRecords,
watchedRecords = watchedRecords,
preferFurthestEpisode = preferFurthestEpisode,
)?.let { completed -> content to completed }
}.toMap()
}

View file

@ -42,10 +42,11 @@ fun nextReleasedEpisodeAfter(
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
)
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
return sortedEpisodes
val candidates = sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
.drop(1)
.firstOrNull { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
.filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
}
fun decideSeriesPrimaryAction(
@ -54,6 +55,7 @@ fun decideSeriesPrimaryAction(
progressRecords: List<WatchingProgressRecord>,
watchedRecords: List<WatchingWatchedRecord>,
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
): WatchingSeriesPrimaryAction? {
val resumeRecord = resumeProgressForSeries(
content = content,
@ -63,6 +65,7 @@ fun decideSeriesPrimaryAction(
content = content,
progressRecords = progressRecords,
watchedRecords = watchedRecords,
preferFurthestEpisode = preferFurthestEpisode,
)
if (shouldPreferResume(resumeRecord = resumeRecord, latestCompletedEpisode = latestCompletedEpisode)) {
@ -78,9 +81,10 @@ fun decideSeriesPrimaryAction(
todayIsoDate = todayIsoDate,
)
} else {
episodes
val sorted = episodes
.sortedWith(compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }))
.firstOrNull { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
val released = sorted.filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } ?: released.firstOrNull()
}
return nextEpisode?.let { episode ->

View file

@ -81,46 +81,53 @@ fun latestCompletedSeriesEpisode(
content: WatchingContentRef,
progressRecords: List<WatchingProgressRecord>,
watchedRecords: List<WatchingWatchedRecord>,
preferFurthestEpisode: Boolean = true,
): WatchingCompletedEpisode? {
val progressMarker = progressRecords
.asSequence()
.filter { record ->
record.content == content &&
record.isCompleted &&
record.seasonNumber != null &&
record.episodeNumber != null
}
.mapNotNull { record ->
val seasonNumber = record.seasonNumber ?: return@mapNotNull null
val episodeNumber = record.episodeNumber ?: return@mapNotNull null
WatchingCompletedEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = record.lastUpdatedEpochMs,
)
}
.maxByOrNull { marker -> marker.markedAtEpochMs }
val watchedMarker = watchedRecords
.asSequence()
.filter { record ->
record.content == content &&
record.seasonNumber != null &&
record.episodeNumber != null
}
.mapNotNull { record ->
val seasonNumber = record.seasonNumber ?: return@mapNotNull null
val episodeNumber = record.episodeNumber ?: return@mapNotNull null
WatchingCompletedEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = record.markedAtEpochMs,
)
}
.maxByOrNull { marker -> marker.markedAtEpochMs }
return listOfNotNull(progressMarker, watchedMarker)
.maxByOrNull { marker -> marker.markedAtEpochMs }
val ordering = if (preferFurthestEpisode) {
compareBy<WatchingCompletedEpisode>(
{ normalizeSeasonNumber(it.seasonNumber) },
{ it.episodeNumber },
{ it.markedAtEpochMs },
)
} else {
compareBy<WatchingCompletedEpisode> { it.markedAtEpochMs }
}
val allMarkers = buildList {
progressRecords
.asSequence()
.filter { record ->
record.content == content &&
record.isCompleted &&
record.seasonNumber != null &&
record.episodeNumber != null
}
.mapNotNullTo(this) { record ->
val seasonNumber = record.seasonNumber ?: return@mapNotNullTo null
val episodeNumber = record.episodeNumber ?: return@mapNotNullTo null
WatchingCompletedEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = record.lastUpdatedEpochMs,
)
}
watchedRecords
.asSequence()
.filter { record ->
record.content == content &&
record.seasonNumber != null &&
record.episodeNumber != null
}
.mapNotNullTo(this) { record ->
val seasonNumber = record.seasonNumber ?: return@mapNotNullTo null
val episodeNumber = record.episodeNumber ?: return@mapNotNullTo null
WatchingCompletedEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = record.markedAtEpochMs,
)
}
}
return allMarkers.maxWithOrNull(ordering)
}
fun normalizeSeasonNumber(seasonNumber: Int?): Int = seasonNumber?.coerceAtLeast(0) ?: 0

View file

@ -12,6 +12,7 @@ import kotlinx.serialization.json.Json
private data class StoredContinueWatchingPreferences(
val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true,
)
object ContinueWatchingPreferencesRepository {
@ -56,6 +57,7 @@ object ContinueWatchingPreferencesRepository {
ContinueWatchingPreferencesUiState(
isVisible = stored.isVisible,
style = stored.style,
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
)
} else {
ContinueWatchingPreferencesUiState()
@ -74,12 +76,19 @@ object ContinueWatchingPreferencesRepository {
persist()
}
fun setUpNextFromFurthestEpisode(enabled: Boolean) {
ensureLoaded()
_uiState.value = _uiState.value.copy(upNextFromFurthestEpisode = enabled)
persist()
}
private fun persist() {
ContinueWatchingPreferencesStorage.savePayload(
json.encodeToString(
StoredContinueWatchingPreferences(
isVisible = _uiState.value.isVisible,
style = _uiState.value.style,
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
),
),
)

View file

@ -103,6 +103,7 @@ data class ContinueWatchingItem(
data class ContinueWatchingPreferencesUiState(
val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true,
)
internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {

View file

@ -75,4 +75,74 @@ class SeriesContinuityTest {
assertEquals("show:1:2", action.videoId)
assertEquals(1_500L, action.resumePositionMs)
}
@Test
fun decideSeriesPrimaryAction_skips_specials_for_initial_play() {
val episodesWithSpecials = listOf(
WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, title = "Pilot", releasedDate = "2026-01-15"),
WatchingReleasedEpisode(videoId = "ep2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-22"),
WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 2, episodeNumber = 1, title = "S2 Premiere", releasedDate = "2026-03-01"),
)
val action = decideSeriesPrimaryAction(
content = show,
episodes = episodesWithSpecials,
progressRecords = emptyList(),
watchedRecords = emptyList(),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Play S1E1", action.label)
assertEquals("show:1:1", action.videoId)
}
@Test
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
val specialsOnly = listOf(
WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
WatchingReleasedEpisode(videoId = "sp2", seasonNumber = 0, episodeNumber = 2, title = "Special 2", releasedDate = "2026-01-15"),
)
val action = decideSeriesPrimaryAction(
content = show,
episodes = specialsOnly,
progressRecords = emptyList(),
watchedRecords = emptyList(),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Play S0E1", action.label)
}
@Test
fun decideSeriesPrimaryAction_skips_watched_episodes_after_rewatch() {
val twoSeasons = listOf(
WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, title = "S1E1", releasedDate = "2026-01-01"),
WatchingReleasedEpisode(videoId = "ep2", seasonNumber = 1, episodeNumber = 2, title = "S1E2", releasedDate = "2026-01-08"),
WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 1, episodeNumber = 3, title = "S1E3", releasedDate = "2026-01-15"),
WatchingReleasedEpisode(videoId = "ep4", seasonNumber = 2, episodeNumber = 1, title = "S2E1", releasedDate = "2026-03-01"),
WatchingReleasedEpisode(videoId = "ep5", seasonNumber = 2, episodeNumber = 2, title = "S2E2", releasedDate = "2026-03-08"),
)
val action = decideSeriesPrimaryAction(
content = show,
episodes = twoSeasons,
progressRecords = listOf(
// All of season 1 completed
WatchingProgressRecord(content = show, videoId = "show:1:1", seasonNumber = 1, episodeNumber = 1, lastUpdatedEpochMs = 100L, isCompleted = true),
WatchingProgressRecord(content = show, videoId = "show:1:2", seasonNumber = 1, episodeNumber = 2, lastUpdatedEpochMs = 200L, isCompleted = true),
WatchingProgressRecord(content = show, videoId = "show:1:3", seasonNumber = 1, episodeNumber = 3, lastUpdatedEpochMs = 300L, isCompleted = true),
// S2E1 completed
WatchingProgressRecord(content = show, videoId = "show:2:1", seasonNumber = 2, episodeNumber = 1, lastUpdatedEpochMs = 400L, isCompleted = true),
// Re-watched S1E1 recently — newer timestamp but earlier episode
WatchingProgressRecord(content = show, videoId = "show:1:1", seasonNumber = 1, episodeNumber = 1, lastUpdatedEpochMs = 900L, isCompleted = true),
),
watchedRecords = emptyList(),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Up Next S2E2", action.label)
assertEquals("show:2:2", action.videoId)
}
}

View file

@ -84,4 +84,63 @@ class WatchingPoliciesTest {
assertEquals(3, latestCompleted.episodeNumber)
assertEquals(300L, latestCompleted.markedAtEpochMs)
}
@Test
fun latestCompletedSeriesEpisode_picks_furthest_episode_even_with_older_timestamp() {
val latestCompleted = latestCompletedSeriesEpisode(
content = show,
progressRecords = listOf(
WatchingProgressRecord(
content = show,
videoId = "show:1:5",
seasonNumber = 1,
episodeNumber = 5,
lastUpdatedEpochMs = 100L,
isCompleted = true,
),
WatchingProgressRecord(
content = show,
videoId = "show:1:1",
seasonNumber = 1,
episodeNumber = 1,
lastUpdatedEpochMs = 600L,
isCompleted = true,
),
),
watchedRecords = emptyList(),
)
assertNotNull(latestCompleted)
assertEquals(1, latestCompleted.seasonNumber)
assertEquals(5, latestCompleted.episodeNumber)
}
@Test
fun latestCompletedSeriesEpisode_picks_furthest_season_over_recent_rewatch() {
val latestCompleted = latestCompletedSeriesEpisode(
content = show,
progressRecords = listOf(
WatchingProgressRecord(
content = show,
videoId = "show:2:3",
seasonNumber = 2,
episodeNumber = 3,
lastUpdatedEpochMs = 200L,
isCompleted = true,
),
),
watchedRecords = listOf(
WatchingWatchedRecord(
content = show,
seasonNumber = 1,
episodeNumber = 1,
markedAtEpochMs = 500L,
),
),
)
assertNotNull(latestCompleted)
assertEquals(2, latestCompleted.seasonNumber)
assertEquals(3, latestCompleted.episodeNumber)
}
}