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>
203 lines
9.9 KiB
Swift
203 lines
9.9 KiB
Swift
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)) }
|
||
}
|
||
}
|