tv-anarchy/Sources/TVAnarchy/MediaCards.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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