From d1753152609189a8788a66ae1f7cdd8d5861548a Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 03:01:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(home):=20=E2=9C=A8=20Continue-Watching=20p?= =?UTF-8?q?lay=20interrupts=20with=20an=20undoable=20recovery=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Sources/TVAnarchy/HomeView.swift | 20 ++++++++ Sources/TVAnarchyCore/PlayerController.swift | 8 +++ .../TVAnarchyCore/PlaylistController.swift | 51 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/Sources/TVAnarchy/HomeView.swift b/Sources/TVAnarchy/HomeView.swift index a3c31f5..688497b 100644 --- a/Sources/TVAnarchy/HomeView.swift +++ b/Sources/TVAnarchy/HomeView.swift @@ -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) diff --git a/Sources/TVAnarchyCore/PlayerController.swift b/Sources/TVAnarchyCore/PlayerController.swift index f75a3b0..98963a1 100644 --- a/Sources/TVAnarchyCore/PlayerController.swift +++ b/Sources/TVAnarchyCore/PlayerController.swift @@ -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?) { diff --git a/Sources/TVAnarchyCore/PlaylistController.swift b/Sources/TVAnarchyCore/PlaylistController.swift index 6addd47..b7e70ca 100644 --- a/Sources/TVAnarchyCore/PlaylistController.swift +++ b/Sources/TVAnarchyCore/PlaylistController.swift @@ -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).