tv-anarchy/Sources/TVAnarchy/HomeView.swift
Natalie d175315260 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>
2026-06-30 03:01:57 -04:00

203 lines
9.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import TVAnarchyCore
/// Netflix-style landing: horizontal poster rails. Continue Watching up top, then
/// a rail per category. Tapping a tile plays/resumes on the active host; deeper
/// browsing (seasons/episodes) lives in the Library tab.
struct HomeView: View {
@Bindable var library: LibraryController
@Bindable var player: PlayerController
@Bindable var playlist: PlaylistController
var offline: OfflineCacheController?
var downloads: DownloadsController?
/// Switch to the Library tab (after setting selectedShow/selectedCategory).
let openLibrary: () -> Void
/// Per-rail paging position (keyed by category) for the buttons.
@State private var railIndex: [String: Int] = [:]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
if library.refreshing || library.rebuildingIndex {
ScanningBanner(progress: library.scanProgress, total: library.scanTotal,
label: library.rebuildingIndex ? "Indexing on black" : "Scanning library")
}
if library.showContinueWatchingOnHome {
let continuing = library.homeContinueWatching
if !continuing.isEmpty {
rail("Continue Watching", onTitle: nil) {
ForEach(continuing) { item in
Button { play(continue: item) } label: { ContinueCard(item: item, isDownloaded: library.isDownloaded(path: item.path), downloadProgress: library.downloadProgress(path: item.path)) }
.buttonStyle(.plain)
}
}
}
}
if library.showRecentlyAddedOnHome {
let recent = library.recentlyAdded()
if !recent.isEmpty {
rail("Recently Added", onTitle: nil) {
ForEach(recent) { show in
Button { open(show: show) } label: { ShowPoster(show: show, width: 132, watchState: show.watchState(watchedPaths: library.playedPaths), downloadState: library.downloadState(for: show)) }
.buttonStyle(.plain)
}
}
}
}
ForEach(library.homeCategories, id: \.self) { cat in
let shows = Array(library.homeShows(in: cat).prefix(40))
if !shows.isEmpty { posterRail(cat, shows) }
}
if library.shows.isEmpty && !library.refreshing {
Text("Library is empty — open Library and Refresh on your home network.")
.foregroundStyle(.secondary)
}
}
.padding(20)
}
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .principal) { MiniTransport(controller: player) }
ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) }
}
.overlay(alignment: .bottom) {
if let msg = player.actionMessage, !PlayerController.shouldDeferToOfflineCacheUI(msg) {
VStack(spacing: 6) {
Text(msg).font(.callout)
if msg.contains("%") {
if let pct = parsePercent(msg) {
ProgressView(value: pct)
Text("\(Int(pct * 100))%").font(.caption2).foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 14).padding(.vertical, 10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)).padding(.bottom, 16)
.onTapGesture { copyToClipboard(msg) }.help("Click to copy")
.task(id: msg) {
let secs = msg.contains("Downloading") || msg.contains("not downloaded") ? 12.0 : 4.0
try? await Task.sleep(for: .seconds(secs))
player.note(nil)
}
}
}
.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
// owned by the unified WatchHistoryController (started in Root). The
// controller's background poll + post-record refreshes keep everything live.
// We still poke refreshContinue + occasional black sync on appear for
// immediate freshness after a cold launch.
.task {
library.refreshContinueWatching()
await library.syncWatchHistory()
}
}
/// A rail with an optional tappable title (Continue Watching uses this; no
/// scroll buttons since its cards aren't shows).
private func rail<C: View>(_ title: String, onTitle: (() -> Void)?,
@ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.title3).bold()
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) { content() }.padding(.bottom, 4)
}
}
}
/// A category poster rail: tappable title ( category), fixed-width tiles
/// (no overlap), and buttons that page through it.
private func posterRail(_ cat: String, _ shows: [CachedShow]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Button { open(category: cat) } label: {
HStack(spacing: 4) { Text(LibraryConfig.label(cat)).font(.title3).bold(); Image(systemName: "chevron.right").font(.caption) }
}
.buttonStyle(.plain)
.help("Browse the \(LibraryConfig.label(cat)) category in Library")
ScrollViewReader { proxy in
HStack(spacing: 6) {
railButton("chevron.left", help: "Page left in this rail") { page(cat, shows, -1, proxy) }
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(shows) { show in
Button { open(show: show) } label: { ShowPoster(show: show, width: 132, watchState: show.watchState(watchedPaths: library.playedPaths), downloadState: library.downloadState(for: show)) }
.buttonStyle(.plain).id(show.id)
}
}
.padding(.bottom, 4)
}
railButton("chevron.right", help: "Page right in this rail") { page(cat, shows, +1, proxy) }
}
}
}
}
private func railButton(_ icon: String, help: String? = nil, _ action: @escaping () -> Void) -> some View {
let b = Button(action: action) {
Image(systemName: icon).font(.body).padding(8).background(.thinMaterial, in: Circle())
}
.buttonStyle(.plain)
if let h = help {
return AnyView(b.help(h))
} else {
return AnyView(b)
}
}
/// Page a rail left/right by a few tiles, scrolling to the new lead item.
private func page(_ cat: String, _ shows: [CachedShow], _ dir: Int, _ proxy: ScrollViewProxy) {
guard !shows.isEmpty else { return }
let step = 4
let next = min(max(0, (railIndex[cat] ?? 0) + dir * step), shows.count - 1)
railIndex[cat] = next
withAnimation { proxy.scrollTo(shows[next].id, anchor: .leading) }
}
private func open(show: CachedShow) {
library.selectedCategory = library.type(of: show.category)
library.selectedShow = show
openLibrary()
}
private func open(category: String) {
library.selectedShow = nil
library.selectedCategory = category
openLibrary()
}
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)
}
private func parsePercent(_ msg: String) -> Double? {
guard let r = msg.range(of: #"(\d+)%"#, options: .regularExpression) else { return nil }
let n = msg[r].dropLast()
return Double(n).map { min(1, max(0, $0 / 100)) }
}
}