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>
360 lines
15 KiB
Swift
360 lines
15 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import AVFoundation
|
|
import TVAnarchyCore
|
|
|
|
/// A muted, looping, aspect-fill video layer for hover previews. AVPlayerLooper +
|
|
/// AVQueuePlayer gives a seamless loop; muted so a grid of them stays silent.
|
|
struct LoopingPreviewPlayer: NSViewRepresentable {
|
|
let url: URL
|
|
func makeNSView(context: Context) -> PreviewLayerView { PreviewLayerView(url: url) }
|
|
func updateNSView(_ view: PreviewLayerView, context: Context) { view.play(url: url) }
|
|
static func dismantleNSView(_ view: PreviewLayerView, coordinator: ()) { view.stop() }
|
|
}
|
|
|
|
final class PreviewLayerView: NSView {
|
|
private let player = AVQueuePlayer()
|
|
private var looper: AVPlayerLooper?
|
|
private var currentURL: URL?
|
|
|
|
init(url: URL) {
|
|
super.init(frame: .zero)
|
|
wantsLayer = true
|
|
let playerLayer = AVPlayerLayer(player: player)
|
|
playerLayer.videoGravity = .resizeAspectFill
|
|
layer = playerLayer
|
|
player.isMuted = true
|
|
play(url: url)
|
|
}
|
|
required init?(coder: NSCoder) { nil }
|
|
|
|
func play(url: URL) {
|
|
guard url != currentURL else { return }
|
|
currentURL = url
|
|
looper = AVPlayerLooper(player: player, templateItem: AVPlayerItem(url: url))
|
|
player.play()
|
|
}
|
|
func stop() { player.pause(); player.removeAllItems(); looper = nil }
|
|
}
|
|
|
|
/// Put text on the clipboard (shared so any view can offer click-to-copy).
|
|
func copyToClipboard(_ text: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
}
|
|
|
|
/// Tap-to-copy error/notice text. Errors are often truncated in the UI; clicking
|
|
/// copies the FULL text to the clipboard (also written to /tmp/tvanarchy.log).
|
|
struct CopyableText: View {
|
|
let text: String
|
|
var systemImage: String = "exclamationmark.triangle"
|
|
var color: Color = .orange
|
|
@State private var copied = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
copied = true
|
|
} label: {
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Image(systemName: copied ? "checkmark.circle.fill" : systemImage)
|
|
Text(copied ? "Copied to clipboard" : text)
|
|
.multilineTextAlignment(.leading).textSelection(.enabled)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(copied ? AnyShapeStyle(.green) : AnyShapeStyle(color))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Click to copy")
|
|
.task(id: copied) {
|
|
guard copied else { return }
|
|
try? await Task.sleep(for: .seconds(1.5)); copied = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Centered capsule search field for the toolbar's `.principal` slot — shared so
|
|
/// every tab's header reads the same: title left · search middle · hosts right.
|
|
struct HeaderSearchField: View {
|
|
@Binding var text: String
|
|
var prompt: String
|
|
var onSubmit: (() -> Void)? = nil
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary).font(.caption)
|
|
TextField(prompt, text: $text)
|
|
.textFieldStyle(.plain)
|
|
.frame(minWidth: 240)
|
|
.onSubmit { onSubmit?() }
|
|
if !text.isEmpty {
|
|
Button { text = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(.quaternary, in: Capsule())
|
|
}
|
|
}
|
|
|
|
/// Live scan/index indicator. When a `total` is known (a full index rebuild — we
|
|
/// know the prior file count) it's a DETERMINATE bar with a real percentage;
|
|
/// otherwise (the fallback NFS walk, no known total) an indeterminate spinner with
|
|
/// a running count. Shown on Home and Library while work is in flight.
|
|
struct ScanningBanner: View {
|
|
let progress: Int
|
|
var total: Int? = nil
|
|
var label: String = "Scanning library"
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
if let total, total > 0 {
|
|
let frac = min(1, Double(progress) / Double(total))
|
|
ProgressView(value: frac).frame(width: 130)
|
|
Text("\(label)… \(progress.formatted()) / \(total.formatted()) · \(Int(frac * 100))%")
|
|
.font(.callout).monospacedDigit()
|
|
} else {
|
|
ProgressView().controlSize(.small)
|
|
Text(progress > 0 ? "\(label)… \(progress.formatted()) folders" : "\(label)…")
|
|
.font(.callout)
|
|
Text("· a few minutes").font(.caption).foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 9)
|
|
.background(.quaternary.opacity(0.4), in: Capsule())
|
|
}
|
|
}
|
|
|
|
/// Reusable poster tile for a show/movie — used by the Library grid, the Home
|
|
/// rails, and show detail. Renders TMDB/keyless artwork (or an initials
|
|
/// placeholder) at 2:3.
|
|
struct ShowPoster: View {
|
|
let show: CachedShow
|
|
/// Fixed tile width for horizontal rails (constrains the poster AND the label,
|
|
/// so labels don't grow greedily and overlap the next tile). nil = flexible
|
|
/// (the Library grid, where the grid cell bounds the width).
|
|
var width: CGFloat? = nil
|
|
/// Optional watched indicator badge (unwatched is shown as no badge).
|
|
var watchState: WatchState? = nil
|
|
/// Download state for the show: has at least one local offline copy, and/or
|
|
/// an active cache download in progress (for clear "downloaded / downloading"
|
|
/// indication on Home and Library grids/rails).
|
|
var downloadState: (hasLocal: Bool, progress: Double?)? = nil
|
|
/// Split tap targets: tapping the THUMBNAIL = "watch next"; tapping the TITLE =
|
|
/// open the detail page. When nil, the tile is inert (an enclosing Button wraps
|
|
/// it, e.g. the Home rails / detail).
|
|
var onThumbnailTap: (() -> Void)? = nil
|
|
var onTitleTap: (() -> Void)? = nil
|
|
|
|
@State private var hovering = false
|
|
@State private var previewURL: URL?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
thumbnailArea
|
|
titleArea
|
|
if !show.countSummary.isEmpty {
|
|
Text(show.countSummary).font(.caption2).foregroundStyle(.secondary)
|
|
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(width: width)
|
|
.frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading)
|
|
}
|
|
|
|
@ViewBuilder private var thumbnailArea: some View {
|
|
let art = poster
|
|
.overlay(alignment: .topTrailing) { watchBadge }
|
|
.overlay(alignment: .bottomTrailing) { downloadBadge }
|
|
.overlay(alignment: .bottom) {
|
|
if let p = downloadState?.progress, p > 0, p < 1 {
|
|
ProgressView(value: p)
|
|
.progressViewStyle(.linear)
|
|
.tint(.blue)
|
|
.frame(height: 2)
|
|
}
|
|
}
|
|
if let onThumbnailTap {
|
|
Button(action: onThumbnailTap) { art }
|
|
.buttonStyle(.plain).help("Watch next")
|
|
} else { art }
|
|
}
|
|
|
|
@ViewBuilder private var titleArea: some View {
|
|
let title = Text(show.name).font(.callout).lineLimit(2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if let onTitleTap {
|
|
Button(action: onTitleTap) { title }.buttonStyle(.plain)
|
|
} else { title }
|
|
}
|
|
|
|
@ViewBuilder private var watchBadge: some View {
|
|
if let watchState, watchState != .unwatched {
|
|
Image(systemName: watchState.icon)
|
|
.font(.caption).padding(5)
|
|
.background(.black.opacity(0.55), in: Circle())
|
|
.foregroundStyle(watchState == .watched ? AnyShapeStyle(.green) : AnyShapeStyle(.white))
|
|
.padding(6)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var downloadBadge: some View {
|
|
if let ds = downloadState, ds.hasLocal || ds.progress != nil {
|
|
let isDownloading = ds.progress != nil && (ds.progress! < 1.0)
|
|
let icon = isDownloading ? "arrow.down.circle" : "arrow.down.circle.fill"
|
|
let color: Color = isDownloading ? .blue : .green
|
|
ZStack(alignment: .center) {
|
|
Image(systemName: icon)
|
|
.font(.caption)
|
|
.foregroundStyle(color)
|
|
if let p = ds.progress, p > 0, p < 1 {
|
|
// Tiny determinate hint around the icon (blue ring).
|
|
Circle()
|
|
.trim(from: 0, to: p)
|
|
.stroke(color, style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
|
|
.frame(width: 14, height: 14)
|
|
.rotationEffect(.degrees(-90))
|
|
}
|
|
}
|
|
.padding(4)
|
|
.background(.black.opacity(0.55), in: Circle())
|
|
.padding(6)
|
|
.help(isDownloading ? "Downloading for offline" : "Downloaded for offline play")
|
|
}
|
|
}
|
|
|
|
/// Size is driven by the rounded-rect base (fills the available width, fixed
|
|
/// 2:3), and artwork is a CLIPPED OVERLAY — an overlay never feeds back into
|
|
/// the parent's layout, so a landscape frame-grab can't blow out the tile width
|
|
/// or overlap its neighbours (the ragged-grid bug).
|
|
@ViewBuilder var poster: some View {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(.quaternary)
|
|
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
|
.frame(maxWidth: .infinity)
|
|
.overlay {
|
|
if let path = show.posterPath, let url = Self.posterURL(path) {
|
|
AsyncImage(url: url) { img in
|
|
img.resizable().scaledToFill()
|
|
} placeholder: {
|
|
initialsLabel
|
|
}
|
|
} else {
|
|
initialsLabel
|
|
}
|
|
}
|
|
// Hover preview (opt-in): a muted looping clip fades in over the art
|
|
// once it's built+fetched. Built lazily on black (intro-skipped), cached.
|
|
.overlay {
|
|
if hovering, let previewURL {
|
|
LoopingPreviewPlayer(url: previewURL).transition(.opacity)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.onHover { h in
|
|
hovering = h
|
|
if h { startPreview() } else { previewURL = nil }
|
|
}
|
|
.animation(.easeIn(duration: 0.25), value: previewURL)
|
|
}
|
|
|
|
/// Debounced lazy preview: ignore quick fly-overs, then build/fetch on black.
|
|
private func startPreview() {
|
|
guard SettingsStore.load().hoverPreviews,
|
|
let path = show.episodes.first?.path else { return }
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(0.5))
|
|
guard hovering else { return } // moved on before debounce
|
|
let url = await PreviewService.shared.preview(forPlumPath: path)
|
|
if hovering { previewURL = url }
|
|
}
|
|
}
|
|
|
|
private var initialsLabel: some View {
|
|
Text(Self.initials(show.name)).font(.system(size: 34, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
static func initials(_ name: String) -> String {
|
|
name.split(separator: " ").prefix(2).compactMap { $0.first.map(String.init) }.joined().uppercased()
|
|
}
|
|
/// posterPath is a remote URL once enriched; otherwise a local frame-grab path.
|
|
static func posterURL(_ poster: String) -> URL? {
|
|
poster.hasPrefix("http") ? URL(string: poster) : URL(fileURLWithPath: poster)
|
|
}
|
|
}
|
|
|
|
/// Reusable "continue watching" card (landscape thumb + title + position).
|
|
struct ContinueCard: View {
|
|
let item: ContinueItem
|
|
/// Whether the target episode for this continue item has a local offline copy.
|
|
var isDownloaded: Bool = false
|
|
/// Active download progress for the target episode (if downloading now).
|
|
var downloadProgress: Double? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ZStack(alignment: .bottomLeading) {
|
|
RoundedRectangle(cornerRadius: 8).fill(.quaternary)
|
|
.frame(width: 200, height: 112)
|
|
.overlay {
|
|
if let path = item.posterPath, let url = ShowPoster.posterURL(path) {
|
|
AsyncImage(url: url) { img in
|
|
img.resizable().scaledToFill()
|
|
} placeholder: { Color.clear }
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
// Scrim so the play glyph + title stay legible over bright art.
|
|
.overlay {
|
|
LinearGradient(colors: [.black.opacity(0.35), .clear],
|
|
startPoint: .bottom, endPoint: .center)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
.overlay(alignment: .topTrailing) {
|
|
if isDownloaded || downloadProgress != nil {
|
|
let isDL = downloadProgress != nil && (downloadProgress! < 1)
|
|
let icon = isDL ? "arrow.down.circle" : "arrow.down.circle.fill"
|
|
let color: Color = isDL ? .blue : .green
|
|
Image(systemName: icon)
|
|
.font(.caption2)
|
|
.foregroundStyle(color)
|
|
.padding(4)
|
|
.background(.black.opacity(0.55), in: Circle())
|
|
.padding(4)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if let p = downloadProgress, p > 0, p < 1 {
|
|
ProgressView(value: p).progressViewStyle(.linear).tint(.blue).frame(height: 2)
|
|
}
|
|
}
|
|
Image(systemName: "play.circle.fill").font(.largeTitle)
|
|
.foregroundStyle(.white.opacity(0.9)).padding(8)
|
|
.shadow(radius: 3)
|
|
}
|
|
.frame(width: 200, height: 112)
|
|
Text(item.show ?? item.title).font(.caption).lineLimit(1).frame(width: 200, alignment: .leading)
|
|
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
/// Resume position when there is one; otherwise which episode plays next —
|
|
/// the rail item points at the NEXT episode, so name it for the user.
|
|
private var caption: String {
|
|
if let pos = item.positionSeconds, pos > 0 {
|
|
return Self.timecode(pos)
|
|
}
|
|
if let s = item.season, let e = item.episode {
|
|
return s == 0 ? "Up next · Special" : "Up next · S\(s)E\(e)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
static func timecode(_ s: Double) -> String {
|
|
let i = Int(s.rounded()); return String(format: "%d:%02d", i / 60, i % 60)
|
|
}
|
|
}
|