The Adult Home now mirrors the main Home's resume affordance: the last adult collection playlist that was fired is persisted to its own lane and surfaced as a "Continue Watching" card that re-queues it on the active host, skipping clips already finished and resuming the first unwatched one at its saved position. Separation: adult playlists get a dedicated AdultPlaylistStore (last-adult-playlist.json), distinct from the adult-stripped non-adult QueueStore (play-queue.json), so the two lanes never bleed together. The main Home's interrupt-recovery banner is filtered to non-adult snapshots, keeping adult titles off the regular Home. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
10 KiB
Swift
205 lines
10 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 {
|
||
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) {
|
||
// Non-adult only: an interrupted adult playlist returns on the Adult Home,
|
||
// never here (keeps adult titles off the main Home).
|
||
if let snap = playlist.recoveryPoint, !snap.isAdult {
|
||
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)) }
|
||
}
|
||
}
|