feat(core,app,android,ios): keep a show in Continue watching after an episode ends

Continue watching listed only part-watched episodes, so finishing one -
the most ordinary moment to want the next - dropped the show from the
shelf. Title::continue_with now reads the episode touched last: a
part-watched one is resumed, a finished one offers the episode after it
("Next: S01E04"), and one reset to the start (what leaving the shelf
does) keeps the title off. A few seconds of an episode are passed over.

The phones computed their own shelf from episode records. The bridge now
exports the engine's choice as TitleRecord.continue_index, and Play on a
title lands there on every client.

Undo on the phones re-saved a watch state with a fresh time, which both
reordered the shelf and could not express "never played". A new
restore_watch_state puts the state back as it was read.
This commit is contained in:
nvrl committed 2026-10-12 22:05:05 +02:00
1 parent c6b0457c4d
commit a306d02cf1
15 files changed
+225 -68

No files matched your search

@@ -595,14 +595,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
snapshots.forEach { snapshot ->
val state = snapshot.state
engine.saveWatchState(
snapshot.shareId,
snapshot.linkId,
state?.positionSecs ?: 0.0,
state?.durationSecs,
state?.watched ?: false,
)
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
}
}
}.onFailure(::reportError)
@@ -103,11 +103,14 @@ internal fun EmptyState(title: String, body: String) {
}
/**
* Where Play on a title lands: the most recently played part-watched episode,
* else the first unwatched one, else the first — the desktop's `next_up`.
* Where Play on a title lands: what Continue watching offers
* ([TitleRecord.continueIndex]), else the most recently played part-watched
* episode, else the first unwatched one, else the first — the desktop's
* `next_up`.
*/
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
continueIndex?.toInt()?.takeIf { it in playlist.indices }
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
@@ -96,7 +96,13 @@ import uniffi.pstr_android.TitleType
private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
private data class Resumable(
val title: TitleRecord,
val episode: EpisodeRecord,
val index: Int,
/** When the title was last played, which orders the shelf. */
val lastPlayed: Long,
)
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
private data class Tile(val title: TitleRecord, val folded: Int)
@@ -126,14 +132,16 @@ internal fun LibraryScreen(
var matching by remember { mutableStateOf<TitleRecord?>(null) }
// Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else.
// What to go on with is the engine's call (`continueIndex`): the
// part-watched episode, or the one after the episode finished last.
val resumable = remember(state.titles) {
state.titles.mapNotNull { title ->
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.withIndex()
.filter { it.value.resumeAt != null && !it.value.watched }
.maxByOrNull { it.value.lastPlayed }
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
playlist.getOrNull(index)?.let { episode ->
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
}
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
// Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them.
@@ -199,7 +207,7 @@ internal fun LibraryScreen(
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
onResume(title, nextUpIndex(playlist))
onResume(title, nextUpIndex(playlist, title.continueIndex))
}
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
@@ -567,7 +575,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play")
@@ -609,7 +617,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
modifier = Modifier.padding(top = 8.dp),
)
Text(
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
if (entry.episode.resumeAt == null) {
"Next: ${entry.episode.label}"
} else {
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -122,7 +122,7 @@ internal fun TitleScreen(
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
val nextUp = remember(playlist) { nextUpIndex(playlist) }
val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
val upNext = playlist.getOrNull(nextUp)
// One season on screen at a time, opened on the one Play would land in: a
// forty-episode show as stacked sections is one long scroll to the part
@@ -26,6 +26,7 @@ class HistoryTest {
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
franchise = emptyList(),
continueIndex = null,
)
@Test
@@ -264,6 +264,9 @@ class ScreenshotTest {
nextEpisode = null,
nextAiringAt = null,
franchise = emptyList(),
// What the engine would pick for these fixtures: the part-watched episode.
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
.takeIf { it >= 0 }?.toUInt(),
)
val SERIES = title(