Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
8.7 KiB
Swift
183 lines
8.7 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)
|
||
}
|
||
}
|
||
}
|
||
.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) {
|
||
// 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 {
|
||
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)) }
|
||
}
|
||
}
|