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 = [] 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) } }