tv-anarchy/Sources/TVAnarchy/LibraryView.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

413 lines
19 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
/// Black-side paths that have a play event drives the watched badges + the
/// thumbnail-tap "watch next". Loaded from the watchlog (cheap), refreshed in `.task`.
@State private var played: Set<String> = []
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 {
played = WatchHistory.playedPaths()
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.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: 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)
}
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
ShowPoster(show: show,
watchState: show.watchState(watchedPaths: played),
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") }
}
}
}
}
/// 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: played) ?? 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 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
/// 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.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).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")
}
} 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
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)
}
/// 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)
}
}