tv-anarchy/Sources/TVAnarchy/LibraryView.swift
Natalie b8b148b788 feat(library): add queue context menu for shows
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-08 22:40:53 -07:00

364 lines
16 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
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)
} 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") }
.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(show.category.capitalized) {
library.selectedShow = nil; library.selectedCategory = show.category
}
}
chevron; Text(show.name).bold().lineLimit(1)
} else if let cat = library.selectedCategory {
chevron; Text(cat.capitalized).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.selectedCategory == nil && library.query.isEmpty
&& !library.continueWatching.isEmpty {
continueRail
}
showGrid
}
.padding(20)
}
}
@ViewBuilder private var actionBanner: some View {
if let msg = player.actionMessage {
Text(msg)
.font(.callout)
.padding(.horizontal, 14).padding(.vertical, 10)
.background(.thinMaterial, in: Capsule())
.padding(.bottom, 16)
.onTapGesture { copyToClipboard(msg) }
.help("Click to copy")
.transition(.move(edge: .bottom).combined(with: .opacity))
.task(id: msg) {
try? await Task.sleep(for: .seconds(4))
player.note(nil)
}
}
}
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: cat.capitalized, category: cat, count: library.count(of: cat))
}
Button { library.showPorn.toggle() } label: {
Image(systemName: library.showPorn ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(library.showPorn ? "Hide adult category" : "Show adult category")
}
}
}
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)
}
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) }
.buttonStyle(.plain)
}
}
}
}
}
private var showGrid: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(library.filteredShows) { show in
Button { library.selectedShow = show } label: { ShowPoster(show: show) }
.buttonStyle(.plain)
.contextMenu {
Button {
playlist.append(show: show)
player.note(addedNote(for: show))
} label: { Label(addToQueueLabel(for: show), systemImage: "text.badge.plus") }
}
}
}
}
/// 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"
}
private func play(continue item: ContinueItem) {
// Single source of truth: play to the active host (selected via the shared
// HostSelector). Resume position comes from the watchlog/VLC recents.
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
/// Saved resume positions (black-side path seconds), for the resume/start choice.
@State private var resumeMap: [String: Double] = [:]
/// The franchise timeline (this series + related movies), chronological.
@State private var franchise: [CachedShow] = []
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.capitalized.isEmpty ? "Movie" : show.category.capitalized }
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).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 { play(episode: nil) } label: { Label("Resume", systemImage: "play.fill") }
Button { play(episode: show.episodes.first, resume: 0) } label: {
Label("Start from beginning", systemImage: "backward.end.fill")
}
} 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("Season \(season)") {
ForEach(show.episodes(inSeason: season)) { ep in
HStack {
Text("E\(ep.episode)").monospacedDigit().foregroundStyle(.secondary).frame(width: 38, alignment: .leading)
Text(ep.label).lineLimit(1)
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)
}
}
.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") }
}
}
}
}
}
}
.task {
resumeMap = library.resumePositions()
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)
}
/// 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)
}
private func play(episode: CachedEpisode?, resume: Double? = nil) {
// Single source of truth: play to the active host (set via the shared
// HostSelector, which defaults to the TV/black).
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)
}
}