feat(home): ✨ Continue-Watching play interrupts with an undoable recovery point
Pressing play on a Continue card replaces the queue and fires the show (enqueue already does replace:true), but there was no way back to what you were watching. - QueueSnapshot + PlaylistController.captureRecovery/restoreRecoveryPoint: snapshot the current queue and the live playback path/position before the interrupt. - PlayerController.currentlyPlaying exposes the active path + position for the snapshot. - Home grabs a recovery point before playContinue and shows a "Return to previous" banner that re-queues from where you left off and resumes the saved position. - Clear the recovery point when the play is a genuine no-op (no player selected), so we never offer a bogus undo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
82ed75cd08
commit
d175315260
3 changed files with 79 additions and 0 deletions
|
|
@ -81,6 +81,22 @@ struct HomeView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let snap = playlist.recoveryPoint {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "arrow.uturn.backward.circle.fill").foregroundStyle(.secondary)
|
||||||
|
Text("Interrupted “\(snap.label)”").font(.callout).lineLimit(1)
|
||||||
|
Button("Return") { playlist.restoreRecoveryPoint(on: player) }
|
||||||
|
.buttonStyle(.borderedProminent).controlSize(.small)
|
||||||
|
Button { playlist.clearRecovery() } label: { Image(systemName: "xmark") }
|
||||||
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("Dismiss")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.bottom, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.default, value: playlist.recoveryPoint)
|
||||||
.animation(.default, value: player.actionMessage)
|
.animation(.default, value: player.actionMessage)
|
||||||
.task { await library.refreshIfStale() }
|
.task { await library.refreshIfStale() }
|
||||||
// Watch state (playedPaths, resumes, episode fracs, continue rail) is now
|
// Watch state (playedPaths, resumes, episode fracs, continue rail) is now
|
||||||
|
|
@ -166,10 +182,14 @@ struct HomeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func play(continue item: ContinueItem) {
|
private func play(continue item: ContinueItem) {
|
||||||
|
// Snapshot what's playing now so the interrupt is undoable (the banner's
|
||||||
|
// "Return to previous"). Taken before the queue is replaced below.
|
||||||
|
playlist.captureRecovery(from: player)
|
||||||
// Unified queue: continuing a series queues the rest of the show from here.
|
// Unified queue: continuing a series queues the rest of the show from here.
|
||||||
if playlist.playContinue(item, shows: library.shows, on: player) { return }
|
if playlist.playContinue(item, shows: library.shows, on: player) { return }
|
||||||
guard let kind = player.activeKind,
|
guard let kind = player.activeKind,
|
||||||
let req = library.launchRequest(continue: item, targetKind: kind) else {
|
let req = library.launchRequest(continue: item, targetKind: kind) else {
|
||||||
|
playlist.clearRecovery() // nothing happened — don't offer a bogus undo
|
||||||
player.note("No player selected"); return
|
player.note("No player selected"); return
|
||||||
}
|
}
|
||||||
player.launch(req, series: item.show, resumeSeconds: item.positionSeconds)
|
player.launch(req, series: item.show, resumeSeconds: item.positionSeconds)
|
||||||
|
|
|
||||||
|
|
@ -1020,6 +1020,14 @@ public final class PlayerController {
|
||||||
/// play-from-here feature falls back to a single launch when it can't).
|
/// play-from-here feature falls back to a single launch when it can't).
|
||||||
public var canEnqueue: Bool { active is Enqueueable }
|
public var canEnqueue: Bool { active is Enqueueable }
|
||||||
|
|
||||||
|
/// Best-effort path + live position of what's playing right now, used to
|
||||||
|
/// snapshot a recovery point before an interrupting play replaces the queue.
|
||||||
|
/// nil when nothing is playing (no fired path).
|
||||||
|
public var currentlyPlaying: (path: String, position: Double)? {
|
||||||
|
guard let path = lastReportedPath ?? playbackQueuePaths.first else { return nil }
|
||||||
|
return (path, snapshot(activeID).status.position ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the launched-series context (so the Sub/Dub track choice keys to the
|
/// Set the launched-series context (so the Sub/Dub track choice keys to the
|
||||||
/// right series) when playback is driven by the queue rather than `launch`.
|
/// right series) when playback is driven by the queue rather than `launch`.
|
||||||
public func setActiveContext(series: String?, category: String?) {
|
public func setActiveContext(series: String?, category: String?) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,26 @@ public struct QueueItem: Identifiable, Sendable, Equatable, Codable {
|
||||||
public var isAdult: Bool { LibraryConfig.isAdult(path: path) }
|
public var isAdult: Bool { LibraryConfig.isAdult(path: path) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A saved snapshot of the queue (and where playback was) taken right before an
|
||||||
|
/// interrupting play replaces it — so the user can jump back to what they were
|
||||||
|
/// watching. Session-only (not persisted): a recovery point is meaningless across
|
||||||
|
/// a relaunch, and `items` may hold adult paths we never write to disk.
|
||||||
|
public struct QueueSnapshot: Sendable, Equatable {
|
||||||
|
public let items: [QueueItem]
|
||||||
|
/// The path that was playing when the snapshot was taken (nil if nothing was).
|
||||||
|
public let resumePath: String?
|
||||||
|
/// Live position (seconds) of `resumePath` at snapshot time.
|
||||||
|
public let resumeSeconds: Double?
|
||||||
|
public init(items: [QueueItem], resumePath: String?, resumeSeconds: Double?) {
|
||||||
|
self.items = items; self.resumePath = resumePath; self.resumeSeconds = resumeSeconds
|
||||||
|
}
|
||||||
|
/// Short label for the "return to previous" affordance.
|
||||||
|
public var label: String {
|
||||||
|
if let resumePath, let item = items.first(where: { $0.path == resumePath }) { return item.title }
|
||||||
|
return items.first?.title ?? "previous queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Boundary between Media Management (Library data, watch state via continueWatching)
|
/// Boundary between Media Management (Library data, watch state via continueWatching)
|
||||||
/// and Playback Execution (viewer clients via enqueue/launch on PlayerController).
|
/// and Playback Execution (viewer clients via enqueue/launch on PlayerController).
|
||||||
/// This class turns pure library episodes into queues for the playback piece.
|
/// This class turns pure library episodes into queues for the playback piece.
|
||||||
|
|
@ -83,6 +103,9 @@ public enum AutoPlaylist: String, CaseIterable, Sendable {
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class PlaylistController {
|
public final class PlaylistController {
|
||||||
public private(set) var queue: [QueueItem] = []
|
public private(set) var queue: [QueueItem] = []
|
||||||
|
/// The most recent pre-interrupt snapshot, surfaced as a "return to previous"
|
||||||
|
/// affordance. Cleared once consumed or when a new interrupt overwrites it.
|
||||||
|
public private(set) var recoveryPoint: QueueSnapshot?
|
||||||
public private(set) var smartPlaylists: [SmartPlaylist] = SmartPlaylistStore.load()
|
public private(set) var smartPlaylists: [SmartPlaylist] = SmartPlaylistStore.load()
|
||||||
private let library: LibraryController
|
private let library: LibraryController
|
||||||
public init(library: LibraryController) {
|
public init(library: LibraryController) {
|
||||||
|
|
@ -161,6 +184,34 @@ public final class PlaylistController {
|
||||||
}
|
}
|
||||||
public func clear() { queue.removeAll(); persist() }
|
public func clear() { queue.removeAll(); persist() }
|
||||||
|
|
||||||
|
// MARK: recovery point (interrupt → undo)
|
||||||
|
|
||||||
|
/// Snapshot the current queue + live playback position before an interrupting
|
||||||
|
/// play replaces it. No-op snapshot (cleared) when there's nothing to return to.
|
||||||
|
public func captureRecovery(from player: PlayerController) {
|
||||||
|
let cur = player.currentlyPlaying
|
||||||
|
guard !queue.isEmpty || cur != nil else { recoveryPoint = nil; return }
|
||||||
|
recoveryPoint = QueueSnapshot(items: queue, resumePath: cur?.path, resumeSeconds: cur?.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearRecovery() { recoveryPoint = nil }
|
||||||
|
|
||||||
|
/// Restore the last recovery point: re-queue from where playback was (the saved
|
||||||
|
/// item forward, so you land back on what you were watching) and fire it,
|
||||||
|
/// resuming at the saved position. Clears the recovery point once consumed.
|
||||||
|
public func restoreRecoveryPoint(on player: PlayerController) {
|
||||||
|
guard let snap = recoveryPoint else { return }
|
||||||
|
recoveryPoint = nil
|
||||||
|
if let rp = snap.resumePath, let idx = snap.items.firstIndex(where: { $0.path == rp }) {
|
||||||
|
queue = Array(snap.items[idx...])
|
||||||
|
} else {
|
||||||
|
queue = snap.items
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
guard !queue.isEmpty else { return }
|
||||||
|
play(on: player, resumeFirst: snap.resumeSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: single-item checklist (collection detail view)
|
// MARK: single-item checklist (collection detail view)
|
||||||
|
|
||||||
/// True when a file path is already in the live queue (checklist state).
|
/// True when a file path is already in the live queue (checklist state).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue