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>
280 lines
11 KiB
Swift
280 lines
11 KiB
Swift
#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("Couldn’t download \(clip.title)")
|
||
}
|
||
}
|
||
|
||
private func downloadQueued() async {
|
||
let targets = queued.filter { !offlinePaths.contains($0.path) }
|
||
for c in targets { await download(c) }
|
||
}
|
||
}
|
||
#endif
|