tv-anarchy/Sources/TVAnarchy/GoonCollectionView.swift
Natalie eb0d75a126 feat(adult): ⏱ show clip length in collection detail list
Each clip row in the adult collection detail view now shows its runtime, and
the header shows total runtime of the queued set (for planning a session of a
given length). Durations are probed in one background SSH batch via ffprobe on
black (NUL-delimited paths over stdin, so the eporner filenames with spaces/
quotes/brackets pass verbatim), debounced on filter and capped at 400 per batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:56:59 -04:00

315 lines
12 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.

#if ENABLE_ADULT
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
import TVAnarchyCore
/// Detail view for one adult collection (e.g. "goon"): lists every clip in the
/// collection, shows queued state as a checklist and offline-cached state, and
/// lets the user queue clips, download them to plum's offline cache, or play.
///
/// Opened by tapping a collection card on AdultView. This is what makes a
/// collection tap *do something* instead of silently firing the whole set at a
/// host that may have nothing cached, you see the clips, tick the ones you want,
/// download them offline (managed by the star/trash offline cache), then play.
struct GoonCollectionView: View {
let collection: PornCollection
@Bindable var playlist: PlaylistController
@Bindable var player: PlayerController
@Bindable var library: LibraryController
@Environment(\.dismiss) private var dismiss
@State private var clips: [PornClip] = []
@State private var loading = true
@State private var filter = ""
/// Paths with a download in flight (per-row spinner).
@State private var downloading: Set<String> = []
/// Paths confirmed present in the offline cache (seeded from the index, then
/// updated as downloads complete).
@State private var offlinePaths: Set<String> = []
/// Probed clip durations (seconds), filled lazily in the background per path.
@State private var durations: [String: Double] = [:]
/// Re-keys the duration probe: changes when clips load or the filter settles.
private var probeKey: String { "\(clips.count)#\(filter)" }
private var shown: [PornClip] {
let q = filter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !q.isEmpty else { return clips }
return clips.filter { $0.title.lowercased().contains(q) }
}
private var queued: [PornClip] { clips.filter { playlist.isQueued(path: $0.path) } }
/// Total known runtime of the queued clips (sum of probed durations).
private var queuedSeconds: Double { queued.compactMap { durations[$0.path] }.reduce(0, +) }
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.frame(minWidth: 580, minHeight: 540)
.task { await load() }
.task(id: probeKey) { await probeVisible() }
}
// MARK: header
private var header: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(collection.name.capitalized).font(.title2).bold()
Text(collection.desc).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill").font(.title2).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
.help("Close")
}
HStack(spacing: 8) {
stat("\(clips.count)", "clips")
Text("·").foregroundStyle(.tertiary)
stat("\(clips.filter(\.fresh).count)", "fresh")
Text("·").foregroundStyle(.tertiary)
stat("\(queued.count)", "queued", accent: !queued.isEmpty)
Text("·").foregroundStyle(.tertiary)
stat("\(offlinePaths.count)", "offline", accent: false)
Spacer()
}
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Filter clips (e.g. brain rot, gooner, hypno)…", text: $filter)
.textFieldStyle(.plain)
if !filter.isEmpty {
Button { filter = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain).foregroundStyle(.tertiary)
}
}
.padding(8)
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: 10) {
Button { queueAllFresh() } label: {
Label("Queue all fresh", systemImage: "plus.rectangle.on.rectangle")
}
.controlSize(.small)
.disabled(clips.allSatisfy { !$0.fresh || playlist.isQueued(path: $0.path) })
Button { Task { await downloadQueued() } } label: {
Label("Download queued offline", systemImage: "arrow.down.circle")
}
.controlSize(.small)
.disabled(queued.allSatisfy { offlinePaths.contains($0.path) })
if !queued.isEmpty {
Button(role: .destructive) { clearQueued() } label: {
Label("Clear", systemImage: "xmark")
}
.controlSize(.small)
}
Spacer()
if queuedSeconds > 0 {
Text(DurationProbe.format(queuedSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.help("Total runtime of queued clips")
}
Button { playQueued() } label: {
Label("Play \(queued.count) queued", systemImage: "play.fill")
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
.disabled(queued.isEmpty)
}
}
.padding(16)
}
private func stat(_ value: String, _ label: String, accent: Bool = false) -> some View {
(Text(value).font(.caption.bold()).foregroundStyle(accent ? Color.accentColor : Color.primary)
+ Text(" \(label)").font(.caption).foregroundStyle(.secondary))
}
// MARK: content
@ViewBuilder private var content: some View {
if loading {
ProgressView("Loading \(collection.name)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if shown.isEmpty {
ContentUnavailableView(
filter.isEmpty ? "No clips in \(collection.name)" : "No matches for “\(filter)",
systemImage: "film.stack",
description: Text(filter.isEmpty
? "This collection has no clips in the current library index."
: "Try a different filter term.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(shown) { clip in
row(clip)
Divider()
}
}
.padding(.vertical, 4)
}
}
}
private func row(_ clip: PornClip) -> some View {
let isQueued = playlist.isQueued(path: clip.path)
let isOffline = offlinePaths.contains(clip.path)
return HStack(spacing: 12) {
Button { toggleQueue(clip) } label: {
Image(systemName: isQueued ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(isQueued ? Color.accentColor : .secondary)
}
.buttonStyle(.plain)
.help(isQueued ? "Remove from queue" : "Add to queue")
VStack(alignment: .leading, spacing: 2) {
Text(clip.title).font(.callout).lineLimit(2)
HStack(spacing: 8) {
if let secs = durations[clip.path] {
Label(DurationProbe.format(secs), systemImage: "clock")
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
}
if clip.fresh {
Label("fresh", systemImage: "sparkles")
.font(.caption2).foregroundStyle(.green)
} else if let d = clip.lastPlayed {
HStack(spacing: 3) {
Text("seen").font(.caption2).foregroundStyle(.tertiary)
Text(d, style: .relative).font(.caption2).foregroundStyle(.tertiary)
}
}
if isOffline {
Label("offline", systemImage: "internaldrive")
.font(.caption2).foregroundStyle(.blue)
}
}
}
Spacer(minLength: 8)
if downloading.contains(clip.path) {
ProgressView().controlSize(.small)
} else if isOffline {
Image(systemName: "checkmark.circle")
.foregroundStyle(.blue)
.help("Cached in offline (manage with star/trash in Offline tab)")
} else {
Button { Task { await download(clip) } } label: {
Image(systemName: "arrow.down.circle")
}
.buttonStyle(.plain)
.help("Download to plum offline cache")
}
Button { play(clip) } label: {
Image(systemName: "play.fill").font(.caption)
}
.buttonStyle(.plain)
.help("Play now on \(player.active?.name ?? "the selected host")")
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture { toggleQueue(clip) }
}
// MARK: actions
private func load() async {
loading = true
clips = await playlist.pornClips(collection: collection.name)
offlinePaths = Set(clips.filter { MediaPaths.localCopy(of: $0.path) != nil }.map(\.path))
loading = false
}
/// Fill durations for the currently-shown clips in one background SSH batch.
/// Debounced so fast filter typing doesn't spawn an ssh per keystroke; capped
/// so the "all" collection (hundreds of clips) can't launch an unbounded walk.
private func probeVisible() async {
try? await Task.sleep(for: .milliseconds(350))
if Task.isCancelled { return }
let need = Array(Set(shown.map(\.path)).subtracting(durations.keys)).prefix(400)
guard !need.isEmpty else { return }
let targets = Array(need)
let probed = await Task.detached(priority: .utility) {
DurationProbe.probe(paths: targets)
}.value
if Task.isCancelled || probed.isEmpty { return }
durations.merge(probed) { _, new in new }
}
private func toggleQueue(_ clip: PornClip) {
if playlist.isQueued(path: clip.path) {
playlist.removeFromQueue(path: clip.path)
} else {
playlist.addToQueue(id: clip.path, title: clip.title, path: clip.path)
}
}
private func queueAllFresh() {
for c in clips where c.fresh && !playlist.isQueued(path: c.path) {
playlist.addToQueue(id: c.path, title: c.title, path: c.path)
}
}
private func clearQueued() {
for c in queued { playlist.removeFromQueue(path: c.path) }
}
private func ensureHost() {
if player.active == nil { player.note("No player selected") }
}
private func playQueued() {
guard !queued.isEmpty else { return }
ensureHost()
playlist.play(on: player)
}
/// Play one clip now without disturbing the built-up queue. Marks it played so
/// freshness advances, mirroring the collection-card fire path.
private func play(_ clip: PornClip) {
ensureHost()
PornCollectionService.markPlayed([clip.path])
player.launch(.file(path: clip.path), series: nil, adult: true)
}
private func download(_ clip: PornClip) async {
guard !downloading.contains(clip.path), !offlinePaths.contains(clip.path) else { return }
downloading.insert(clip.path)
defer { downloading.remove(clip.path) }
let ok = await OfflineCacheController.fetchFile(path: clip.path, show: "porn") { msg in
player.note(msg)
}
if ok {
offlinePaths.insert(clip.path)
player.note("Saved offline: \(clip.title)")
} else {
player.note("Couldnt download \(clip.title)")
}
}
private func downloadQueued() async {
let targets = queued.filter { !offlinePaths.contains($0.path) }
for c in targets { await download(c) }
}
}
#endif