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

+1 -7
View File
@@ -516,13 +516,7 @@ final class AppModel {
do {
try await run { engine in
for snapshot in snapshots {
try engine.saveWatchState(
shareId: snapshot.shareId,
linkId: snapshot.linkId,
positionSecs: snapshot.state?.positionSecs ?? 0,
durationSecs: snapshot.state?.durationSecs,
watched: snapshot.state?.watched ?? false
)
try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
}
}
} catch {
+8 -3
View File
@@ -263,9 +263,14 @@ extension View {
func secureContent() -> some View { modifier(SecureContent()) }
}
/// 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`.
func nextUpIndex(_ playlist: [EpisodeRecord]) -> Int {
/// 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`.
func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> Int {
if let index = continueIndex.map(Int.init), playlist.indices.contains(index) {
return index
}
if let resumed = playlist.indices.filter({ playlist[$0].resumeAt != nil }).max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) {
return resumed
}
+19 -10
View File
@@ -10,6 +10,8 @@ struct Resumable: Identifiable {
let title: TitleRecord
let episode: EpisodeRecord
let index: Int
/// When the title was last played, which orders the shelf.
let lastPlayed: Int64
var id: String { "\(episode.shareId)/\(episode.linkId)" }
}
@@ -30,17 +32,22 @@ private struct Shelf: Identifiable {
}
/// 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.
/// episodes of the same series is a shelf with room for nothing else. Which
/// episode is the engine's call (`continueIndex`): the part-watched one, or the
/// one after the episode finished last.
func resumable(_ titles: [TitleRecord]) -> [Resumable] {
titles.compactMap { title -> Resumable? in
guard let index = title.continueIndex.map(Int.init) else { return nil }
let playlist = title.playlist
guard let index = playlist.indices
.filter({ playlist[$0].resumeAt != nil && !playlist[$0].watched })
.max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed })
else { return nil }
return Resumable(title: title, episode: playlist[index], index: index)
guard playlist.indices.contains(index) else { return nil }
return Resumable(
title: title,
episode: playlist[index],
index: index,
lastPlayed: playlist.map(\.lastPlayed).max() ?? 0
)
}
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
.sorted { $0.lastPlayed > $1.lastPlayed }
.prefix(continueWatchingMax)
.map { $0 }
}
@@ -106,7 +113,7 @@ struct LibraryView: View {
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
TileMenu(heading: title.displayName, caption: title.caption) {
MenuRow("play.fill", playlist.contains { $0.resumeAt != nil } ? "Resume" : "Play") {
onResume(title, nextUpIndex(playlist))
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
}
MenuRow("info.circle", "Open") { onTitle(title) }
MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) }
@@ -426,7 +433,7 @@ private struct FeaturedBanner: View {
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
}
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist)) } label: {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
}
.buttonStyle(.accent)
@@ -463,7 +470,9 @@ private struct ContinueCard: View {
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
.lineLimit(1).padding(.top, 8)
Text([entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · "))
Text(entry.episode.resumeAt == nil
? "Next: \(entry.episode.label)"
: [entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · "))
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
}
.frame(width: 220)
+3 -3
View File
@@ -36,7 +36,7 @@ struct TitleView: View {
self.onOpenTitle = onOpenTitle
// One season on screen at a time, opened on the one Play would land in.
let playlist = title.playlist
let nextUp = nextUpIndex(playlist)
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
}
@@ -44,7 +44,7 @@ struct TitleView: View {
var body: some View {
// Display order across seasons: what previous/next and autoplay walk.
let playlist = title.playlist
let nextUp = nextUpIndex(playlist)
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil
let downloads = DownloadCoordinator.shared.records
@@ -105,7 +105,7 @@ struct TitleView: View {
.onChange(of: title.key) {
overviewOpen = false
let playlist = title.playlist
let upNext = playlist.indices.contains(nextUpIndex(playlist)) ? playlist[nextUpIndex(playlist)] : nil
let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil
seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
}
}