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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord {
|
||||
manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)],
|
||||
displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
|
||||
directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil,
|
||||
franchise: []
|
||||
franchise: [], continueIndex: nil
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user