tv-anarchy/Sources/TVAnarchy/HomeView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

183 lines
8.7 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)
}
}
}
.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)) }
}
}