mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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(
|
||||
|
||||
Reference in new issue
Block a user