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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user