tv-anarchy/Sources/TVAnarchy/LibraryView.swift

508 lines
26 KiB
Swift
Raw Permalink Normal View History

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 {
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)
}
}