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:
Natalie 2026-06-30 03:01:57 -04:00
parent 82ed75cd08
commit d175315260
3 changed files with 79 additions and 0 deletions

View file

@ -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)
.task { await library.refreshIfStale() }
// Watch state (playedPaths, resumes, episode fracs, continue rail) is now
@ -166,10 +182,14 @@ struct HomeView: View {
}
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.
if playlist.playContinue(item, shows: library.shows, on: player) { return }
guard let kind = player.activeKind,
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.launch(req, series: item.show, resumeSeconds: item.positionSeconds)

View file

@ -1020,6 +1020,14 @@ public final class PlayerController {
/// play-from-here feature falls back to a single launch when it can't).
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
/// right series) when playback is driven by the queue rather than `launch`.
public func setActiveContext(series: String?, category: String?) {

View file

@ -17,6 +17,26 @@ public struct QueueItem: Identifiable, Sendable, Equatable, Codable {
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)
/// and Playback Execution (viewer clients via enqueue/launch on PlayerController).
/// This class turns pure library episodes into queues for the playback piece.
@ -83,6 +103,9 @@ public enum AutoPlaylist: String, CaseIterable, Sendable {
@MainActor
public final class PlaylistController {
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()
private let library: LibraryController
public init(library: LibraryController) {
@ -161,6 +184,34 @@ public final class PlaylistController {
}
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)
/// True when a file path is already in the live queue (checklist state).