tv-anarchy/Sources/TVAnarchy/HomeView.swift
Natalie d793d54dfb feat(adult): Continue Watching last adult playlist + separate adult/non-adult playlist lanes
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>
2026-06-30 03:28:12 -04:00

205 lines
10 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 {
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)) }
}
}