tv-anarchy/Sources/TVAnarchy/GoonCollectionView.swift
Natalie ee7efad888 feat(adult): 🍿 collection detail view — clip checklist + offline download
Tapping a collection card on the Adult page now opens a detail view listing
every clip in that collection (goon, pmv, etc) instead of silently firing the
whole set at a host with nothing cached. Each row shows:
- queued state as a tickable checklist (build a session clip by clip)
- freshness / last-played
- offline-cached state, with a per-clip download-to-offline button

Plus a title filter (find e.g. a specific 'brain rot'/gooner clip), queue-all-
fresh, download-all-queued-offline, and play-queued. Downloads land in the
offline cache where the new star/trash row controls manage them. Quick-play the
old fire path stays on the card context menu.

Core: PornCollectionService.clips()/title() expose the full per-collection clip
list with freshness; PlaylistController gains single-item checklist queue ops
(isQueued/addToQueue/removeFromQueue) and pornClips().

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

280 lines
11 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> = []
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) } }
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.frame(minWidth: 580, minHeight: 540)
.task { await load() }
}
// 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()
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 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
}
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