tv-anarchy/Sources/TVAnarchy/LibraryView.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

507 lines
26 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
/// Library browser: a continue-watching rail, a searchable poster grid, and a
/// show seasons episodes drill-down. Play routes to the active player target
/// (black resolves by name; VLC by file path). Fully usable offline from the
/// cached snapshot Refresh rescans ~/media when the mount is up.
struct LibraryView: View {
@Bindable var library: LibraryController
@Bindable var player: PlayerController
@Bindable var playlist: PlaylistController
var offline: OfflineCacheController?
var downloads: DownloadsController?
private let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)]
var body: some View {
Group {
if let show = library.selectedShow {
ShowDetailView(show: show, library: library, player: player, playlist: playlist, offline: offline, downloads: downloads)
} else {
grid
}
}
.toolbar {
ToolbarItem(placement: .navigation) { breadcrumb }
ToolbarItem(placement: .principal) {
HeaderSearchField(text: $library.query, prompt: "Filter shows")
}
ToolbarItemGroup(placement: .primaryAction) {
if library.refreshing { ProgressView().controlSize(.small) }
Button { Task { await library.refresh() } } label: { Image(systemName: "arrow.clockwise") }
.help("Refresh the library index from black (or local MEDIA_ROOTS)")
.disabled(library.refreshing)
HostSelector(controller: player, compact: true)
}
}
.overlay(alignment: .bottom) { actionBanner }
.animation(.default, value: player.actionMessage)
.task {
await library.refreshIfStale()
}
}
/// Clickable breadcrumb: Library / Category / Show. Each segment navigates.
@ViewBuilder private var breadcrumb: some View {
HStack(spacing: 5) {
crumb("Library") { library.selectedShow = nil; library.selectedCategory = nil }
if let show = library.selectedShow {
if !show.category.isEmpty {
chevron; crumb(LibraryConfig.label(library.type(of: show.category))) {
library.selectedShow = nil; library.selectedCategory = library.type(of: show.category)
}
}
chevron; Text(show.name).bold().lineLimit(1)
} else if let cat = library.selectedCategory {
chevron; Text(LibraryConfig.label(cat)).bold()
}
}
}
private func crumb(_ title: String, _ action: @escaping () -> Void) -> some View {
Button(title, action: action).buttonStyle(.plain).foregroundStyle(.tint)
}
private var chevron: some View {
Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary)
}
private var grid: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if library.refreshing || library.rebuildingIndex {
ScanningBanner(progress: library.scanProgress, total: library.scanTotal,
label: library.rebuildingIndex ? "Indexing on black" : "Scanning library")
}
categoryBar
if library.showContinueWatchingOnHome
&& library.selectedCategory == nil && library.query.isEmpty
&& !library.continueWatching.isEmpty {
continueRail
}
showGrid
}
.padding(20)
}
}
@ViewBuilder private var actionBanner: some View {
if let msg = player.actionMessage, !PlayerController.shouldDeferToOfflineCacheUI(msg) {
VStack(spacing: 6) {
Text(msg).font(.callout)
if msg.contains("%"), 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")
.transition(.move(edge: .bottom).combined(with: .opacity))
.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)
}
}
}
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)) }
}
private var categoryBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(title: "All", category: nil, count: library.visibleCount)
ForEach(library.categories, id: \.self) { cat in
chip(title: LibraryConfig.label(cat), category: cat, count: library.count(of: cat))
}
#if ENABLE_ADULT
Button { library.showPorn.toggle() } label: {
Image(systemName: library.showPorn ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(library.showPorn ? "Hide adult category" : "Show adult category")
#endif
}
}
}
private func chip(title: String, category: String?, count: Int) -> some View {
let selected = library.selectedCategory == category
return Button { library.selectedCategory = category } label: {
HStack(spacing: 5) {
Text(title)
Text("\(count)").font(.caption2).foregroundStyle(.secondary)
}
.padding(.horizontal, 10).padding(.vertical, 5)
.background(selected ? AnyShapeStyle(.tint.opacity(0.22)) : AnyShapeStyle(.quaternary),
in: Capsule())
}
.buttonStyle(.plain)
.help(category == nil ? "Show all library categories" : "Filter Library grid to \(title)")
}
private var continueRail: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Continue watching").font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(library.continueWatching) { item in
Button { play(continue: item) } label: { ContinueCard(item: item, isDownloaded: library.isDownloaded(path: item.path), downloadProgress: library.downloadProgress(path: item.path)) }
.buttonStyle(.plain)
}
}
}
}
}
private var showGrid: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(library.filteredShows) { show in
ShowPoster(show: show,
watchState: show.watchState(watchedPaths: library.playedPaths),
downloadState: library.downloadState(for: show),
onThumbnailTap: { watchNext(show) },
onTitleTap: { library.selectedShow = show })
.contextMenu {
Button { library.selectedShow = show } label: { Label("Open", systemImage: "rectangle.expand.vertical") }
Button {
playlist.append(show: show)
player.note(addedNote(for: show))
} label: { Label(addToQueueLabel(for: show), systemImage: "text.badge.plus") }
if show.watchState(watchedPaths: library.playedPaths) == .watched {
Button { rewatch(show) } label: { Label("Rewatch from start", systemImage: "arrow.counterclockwise") }
}
}
}
}
}
/// Context-menu label: a movie adds one file; a series adds every episode.
private func addToQueueLabel(for show: CachedShow) -> String {
guard show.kind == .series else { return "Add to Queue" }
if let n = show.knownEpisodeCount, n > 0 { return "Add \(n) episodes to Queue" }
return "Add show to Queue"
}
private func addedNote(for show: CachedShow) -> String {
if show.kind == .series, let n = show.knownEpisodeCount, n > 0 {
return "Added \(n) episodes of \(show.name) to the queue"
}
return "Added \(show.name) to the queue"
}
/// Thumbnail tap = "watch next": a movie just plays; a series plays its next
/// unwatched episode (rewatch from the start once all are seen) and queues the
/// rest via the unified playlist.
private func watchNext(_ show: CachedShow) {
let next = show.kind == .movie ? nil
: (show.nextUnwatched(watchedPaths: library.playedPaths) ?? show.orderedEpisodes.first)
if show.kind == .series, let next, player.canEnqueue {
playlist.loadFromHere(show: show, startPath: next.path)
player.setActiveContext(series: show.name, category: show.category)
playlist.play(on: player, resumeFirst: nil)
return
}
guard let kind = player.activeKind,
let req = library.launchRequest(show: show, episode: next, targetKind: kind) else {
player.note("No player selected"); return
}
player.launch(req, series: show.kind == .series ? show.name : nil, category: show.category)
}
private func rewatch(_ show: CachedShow) {
library.resetWatchState(for: show)
// played state is now library.playedPaths (from unified watch controller); badges will update on next poll/refresh.
// Then behave exactly like watchNext (first episode, queue rest).
watchNext(show)
}
private func play(continue item: ContinueItem) {
// Unified queue: continuing a series queues the rest of the show from here
// (so S3 follows S2). Falls back to a single launch for movies / hosts that
// can't enqueue. Resume position comes from the watchlog/VLC recents.
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)
}
}
// MARK: - Show detail (seasons episodes)
private struct ShowDetailView: View {
let show: CachedShow
@Bindable var library: LibraryController
@Bindable var player: PlayerController
@Bindable var playlist: PlaylistController
var offline: OfflineCacheController?
var downloads: DownloadsController?
/// The franchise timeline (this series + related movies), chronological.
@State private var franchise: [CachedShow] = []
/// Live from unified watch source (refreshed by background poll + records).
private var resumeMap: [String: Double] { library.resumePositions() }
private func resume(for ep: CachedEpisode) -> Double? {
guard let p = resumeMap[MediaPaths.toRemote(ep.path)], p > 1 else { return nil }
return p
}
/// Movies have no episode list (by design). Series show their count, or when
/// the entry came from the registry title-list rather than a filesystem scan
/// a prompt to rescan, not a misleading "offline".
private var subtitle: String {
if show.kind == .movie { return show.category.isEmpty ? "Movie" : LibraryConfig.label(LibraryConfig.type(of: show.category)) }
if !show.countSummary.isEmpty { return show.countSummary }
return "Episodes not scanned yet — Refresh on your home network"
}
var body: some View {
List {
Section {
HStack(alignment: .top, spacing: 16) {
ShowPoster(show: show, downloadState: library.downloadState(for: show)).frame(width: 120)
VStack(alignment: .leading, spacing: 8) {
Text(show.name).font(.title2).bold()
Text(subtitle).font(.caption).foregroundStyle(.secondary)
if let overview = show.overview, !overview.isEmpty {
Text(overview).font(.callout).foregroundStyle(.secondary).lineLimit(5)
}
HStack(spacing: 10) {
if show.kind == .movie {
Button { play(episode: nil) } label: { Label("Play", systemImage: "play.fill") }
.buttonStyle(.borderedProminent).disabled(player.active == nil)
} else {
Menu {
Button { resumeShow() } label: { Label("Resume", systemImage: "play.fill") }
Button { play(episode: show.orderedEpisodes.first, resume: 0) } label: {
Label("Start from beginning", systemImage: "backward.end.fill")
}
if show.watchState(watchedPaths: library.playedPaths) == .watched {
Button { library.resetWatchState(for: show) } label: { Label("Rewatch (reset state)", systemImage: "arrow.counterclockwise") }
}
} label: { Label("Play", systemImage: "play.fill") }
.menuStyle(.borderlessButton).fixedSize().disabled(player.active == nil)
}
Button { addShowToQueue() } label: {
Label(show.kind == .movie ? "Add to Queue" : "Queue all",
systemImage: "text.badge.plus")
}
.buttonStyle(.bordered)
.disabled(show.kind == .series && show.episodes.isEmpty)
.help("Add to the play queue")
}
}
Spacer()
}
.padding(.vertical, 4)
}
if franchise.count > 1 {
Section("Franchise · chronological (drag to reorder)") {
ForEach(franchise) { item in
franchiseRow(item)
}
.onMove { idx, dst in
franchise.move(fromOffsets: idx, toOffset: dst)
library.reorderFranchise(series: show, order: franchise.map(\.rootDir))
}
}
}
if show.kind == .series {
ForEach(show.seasons, id: \.self) { season in
Section(show.seasonLabel(season)) {
ForEach(show.episodes(inSeason: season)) { ep in
VStack(alignment: .leading, spacing: 3) {
HStack {
Text("E\(ep.episode)").monospacedDigit().foregroundStyle(.secondary).frame(width: 38, alignment: .leading)
Text(ep.label).lineLimit(1)
// Clear per-episode download status (Home/Library pages requirement).
// Green filled = has local offline copy (via DownloadsIndex).
// Blue with progress = actively downloading in the offline cache queue.
if let p = library.downloadProgress(for: ep) ?? (library.isDownloaded(ep) ? 1.0 : nil) {
if p >= 1 {
Image(systemName: "arrow.down.circle.fill")
.font(.caption)
.foregroundStyle(.green)
.help("Downloaded for offline")
} else {
ProgressView(value: p)
.progressViewStyle(.linear)
.frame(width: 22, height: 3)
.tint(.blue)
Image(systemName: "arrow.down.circle")
.font(.caption2)
.foregroundStyle(.blue)
.help("Downloading for offline")
}
}
Spacer()
if let pos = resume(for: ep) {
Menu {
Button { play(episode: ep, resume: pos) } label: {
Label("Resume at \(timecode(pos))", systemImage: "play.fill")
}
Button { play(episode: ep, resume: 0) } label: {
Label("Play from start", systemImage: "backward.end.fill")
}
} label: { Image(systemName: "play.circle.badge.checkmark") }
.menuStyle(.borderlessButton).fixedSize().disabled(player.active == nil)
} else {
Button { play(episode: ep, resume: 0) } label: { Image(systemName: "play.circle") }
.buttonStyle(.borderless).disabled(player.active == nil)
}
}
if let f = watchFraction(for: ep), f > 0 {
// Netflix-style episode progress bar (red, thin). Full for finished eps;
// partial for those with a captured resume position (live-updated while watching).
ProgressView(value: f)
.progressViewStyle(.linear)
.tint(.red)
.frame(height: 2)
.padding(.leading, 38)
}
}
.contextMenu {
Button {
playlist.append(episode: ep, of: show)
player.note("Added \(ep.label) to the queue")
} label: { Label("Add to Queue", systemImage: "text.badge.plus") }
Divider()
Button(role: .destructive) {
let p = ep.path
Task {
let (ok, msg) = await DevicesConfig.markStorageFileBroken(MediaPaths.toRemote(p))
await MainActor.run {
player.note(ok ? msg : "Mark broken failed: \(msg). Manually: ssh storage 'touch \"\(p).broken\"'")
}
}
} label: {
Label("Mark as broken (governor skip)", systemImage: "exclamationmark.octagon.fill")
}
.help("Touches sibling .broken next to the source file on storage. The governor watch/keeper will exclude this file from prefetch forever. Use for files that refuse to play in VLC (corrupt, bad decode, frozen at 0:00, etc.).")
Button {
let p = ep.path
Task {
let (ok, msg) = await DevicesConfig.unmarkStorageFileBroken(MediaPaths.toRemote(p))
await MainActor.run { player.note(ok ? msg : "Unmark failed: \(msg)") }
}
} label: {
Label("Unmark broken (re-enable)", systemImage: "checkmark.circle")
}
.help("Removes the .broken marker so governor watch/keeper will consider the file again.")
}
}
}
}
}
}
.task {
franchise = library.franchiseTimeline(for: show)
}
}
/// One row in the franchise timeline: the series itself (current, marked), or a
/// related movie (play + unlink). Year shown for chronology.
@ViewBuilder private func franchiseRow(_ item: CachedShow) -> some View {
let isCurrent = item.rootDir == show.rootDir
HStack(spacing: 8) {
Image(systemName: isCurrent ? "circle.fill" : "film")
.font(.caption).foregroundStyle(isCurrent ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
Text(item.name).fontWeight(isCurrent ? .bold : .regular).lineLimit(1)
if let y = item.year { Text(String(y)).font(.caption).foregroundStyle(.secondary) }
if isCurrent {
Text("Series").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
Spacer()
if !isCurrent {
Button { playItem(item) } label: { Image(systemName: "play.circle") }
.buttonStyle(.borderless).disabled(player.active == nil)
}
}
.contextMenu {
if !isCurrent {
Button("Open") { library.selectedShow = item }
Button("Not part of this franchise", role: .destructive) {
library.unlinkFromFranchise(series: show, movie: item)
franchise.removeAll { $0.rootDir == item.rootDir }
}
}
}
}
private func timecode(_ s: Double) -> String {
let i = Int(s.rounded()); return String(format: "%d:%02d", i / 60, i % 60)
}
/// Netflix-style per-episode progress: 1.0 for finished ("play" marker),
/// else the latest known fraction (pos/dur) if we captured duration during
/// a live report or finish. Falls back to a small "started" indicator if only
/// a resume pos exists without dur.
private func watchFraction(for ep: CachedEpisode) -> Double? {
let r = MediaPaths.toRemote(ep.path)
if library.playedPaths.contains(r) { return 1.0 }
if let p = library.episodeProgress[r] { return p.fraction }
if resumeMap[r] != nil { return 0.12 } // visible "in progress, dur unknown"
return nil
}
/// Add this entry to the play queue: a movie's file, or all of a series'
/// episodes in order. Confirms via the same banner play actions use.
private func addShowToQueue() {
playlist.append(show: show)
if show.kind == .series {
let n = show.knownEpisodeCount ?? show.episodes.count
player.note("Added \(n) episodes of \(show.name) to the queue")
} else {
player.note("Added \(show.name) to the queue")
}
}
/// Play a franchise entry (a movie) directly on the active host.
private func playItem(_ item: CachedShow) {
guard let kind = player.activeKind,
let req = library.launchRequest(show: item, episode: nil, targetKind: kind) else {
player.note("No player selected"); return
}
player.launch(req, series: item.kind == .series ? item.name : nil, category: item.category)
}
/// Show-level Resume: continue from the resume target (in-progress episode at its
/// position, else next unwatched), queuing the rest by path, so it's correct for
/// merged multi-folder shows.
private func resumeShow() {
guard let t = library.resumeTarget(for: show) else {
play(episode: show.orderedEpisodes.first, resume: 0); return
}
play(episode: t.episode, resume: t.position)
}
private func play(episode: CachedEpisode?, resume: Double? = nil) {
// Unified playlist: playing a specific series episode queues the rest of the
// show from there (specials/movies last) so it plays straight through. A
// movie, or a host that can't enqueue, falls back to a single launch.
if show.kind == .series, let episode, player.canEnqueue {
playlist.loadFromHere(show: show, startPath: episode.path)
player.setActiveContext(series: show.name, category: show.category)
playlist.play(on: player, resumeFirst: resume)
return
}
guard let kind = player.activeKind,
let req = library.launchRequest(show: show, episode: episode, targetKind: kind) else {
player.note("No player selected"); return
}
player.launch(req, series: show.kind == .series ? show.name : nil,
category: show.category, resumeSeconds: resume)
}
}