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>
507 lines
26 KiB
Swift
507 lines
26 KiB
Swift
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)
|
||
}
|
||
}
|
||
|