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>
This commit is contained in:
Natalie 2026-06-30 00:50:05 -04:00
parent 4a2ceb9781
commit ee7efad888
4 changed files with 372 additions and 5 deletions

View file

@ -20,6 +20,8 @@ struct AdultView: View {
@State private var loading = false @State private var loading = false
/// The collection currently being fired (spinner + disable), nil when idle. /// The collection currently being fired (spinner + disable), nil when idle.
@State private var firing: String? @State private var firing: String?
/// Collection whose detail checklist sheet is open (tap a card to set it).
@State private var detail: PornCollection?
private let columns = [GridItem(.adaptive(minimum: 150), spacing: 12)] private let columns = [GridItem(.adaptive(minimum: 150), spacing: 12)]
@ -60,6 +62,9 @@ struct AdultView: View {
} }
} }
.animation(.default, value: player.actionMessage) .animation(.default, value: player.actionMessage)
.sheet(item: $detail) { col in
GoonCollectionView(collection: col, playlist: playlist, player: player, library: library)
}
} }
private func copyToClipboard(_ text: String) { private func copyToClipboard(_ text: String) {
@ -145,7 +150,7 @@ struct AdultView: View {
} }
private func collectionCard(_ col: PornCollection) -> some View { private func collectionCard(_ col: PornCollection) -> some View {
Button { Task { await play(col) } } label: { Button { detail = col } label: {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Text(col.name.capitalized).font(.headline) Text(col.name.capitalized).font(.headline)
@ -167,10 +172,16 @@ struct AdultView: View {
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(col.fresh == 0 || firing != nil) .disabled(firing != nil)
.help(col.fresh == 0 .help("Open \(col.name.capitalized) — see all \(col.total) clips, tick what you want, download to offline or play")
? "Nothing fresh — everything here was played in the last \(PornCollectionService.defaultDays) days" .contextMenu {
: "Play \(min(library.adultQueueCount, col.fresh)) fresh clip\(min(library.adultQueueCount, col.fresh) == 1 ? "" : "s") on \(selectedHostName)") Button("Open clip list", systemImage: "list.bullet") { detail = col }
if col.fresh > 0 {
Button("Quick-play \(min(library.adultQueueCount, col.fresh)) fresh", systemImage: "play.fill") {
Task { await play(col) }
}
}
}
} }
private var emptyState: some View { private var emptyState: some View {

View file

@ -0,0 +1,280 @@
#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

View file

@ -161,6 +161,26 @@ public final class PlaylistController {
} }
public func clear() { queue.removeAll(); persist() } public func clear() { queue.removeAll(); persist() }
// MARK: single-item checklist (collection detail view)
/// True when a file path is already in the live queue (checklist state).
public func isQueued(path: String) -> Bool { queue.contains { $0.path == path } }
/// Append one clip if not already queued (no replace). Used by the collection
/// detail checklist to build a session one clip at a time.
public func addToQueue(id: String, title: String, path: String) {
guard !isQueued(path: path) else { return }
queue.append(QueueItem(id: id, title: title, path: path))
persist()
}
/// Remove every queue entry for a path (checklist un-tick).
public func removeFromQueue(path: String) {
let before = queue.count
queue.removeAll { $0.path == path }
if queue.count != before { persist() }
}
// MARK: generators (replace the queue) // MARK: generators (replace the queue)
/// Replace the queue from a one-tap generator. `limit` caps the queue length. /// Replace the queue from a one-tap generator. `limit` caps the queue length.
@ -326,6 +346,15 @@ public final class PlaylistController {
await loadPornCollections() await loadPornCollections()
} }
/// Every clip in a collection (fresh + already-seen) for the detail checklist,
/// computed off the main actor over the same adult index pool the cards use.
public func pornClips(collection: String) async -> [PornClip] {
let pool = pornPool
return await Task.detached(priority: .utility) {
PornCollectionService.clips(pool: pool, collection: collection)
}.value
}
/// Strip the "EPORNER.COM - [id]" scrape prefix + extension for a readable row. /// Strip the "EPORNER.COM - [id]" scrape prefix + extension for a readable row.
static func prettyPornTitle(_ path: String) -> String { static func prettyPornTitle(_ path: String) -> String {
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension

View file

@ -15,6 +15,20 @@ public struct PornCollection: Identifiable, Sendable, Equatable, Decodable {
} }
} }
/// One clip in a collection's detail listing: its black-side path, a cleaned
/// display title, freshness (unplayed within the window), and last-played date.
/// Drives the per-collection checklist view (queue + offline download).
public struct PornClip: Identifiable, Sendable, Equatable, Hashable {
public let path: String
public let title: String
public let fresh: Bool
public let lastPlayed: Date?
public var id: String { path }
public init(path: String, title: String, fresh: Bool, lastPlayed: Date?) {
self.path = path; self.title = title; self.fresh = fresh; self.lastPlayed = lastPlayed
}
}
/// A named, virtual playlist over the porn pool: an *include* filter (match any /// A named, virtual playlist over the porn pool: an *include* filter (match any
/// substring) with an optional *exclude*, layered on top of the global SKIP /// substring) with an optional *exclude*, layered on top of the global SKIP
/// floor. A file can belong to several collections; nothing is moved on disk. /// floor. A file can belong to several collections; nothing is moved on disk.
@ -193,6 +207,39 @@ public enum PornCollectionService {
for p in paths { log[(p as NSString).lastPathComponent] = stamp } for p in paths { log[(p as NSString).lastPathComponent] = stamp }
PornPlayLog.save(log) PornPlayLog.save(log)
} }
/// Cleaned display title for a clip path: drops the `EPORNER.COM - [id]`
/// scrape prefix and the extension. Mirrors PlaylistController.prettyPornTitle
/// so the collection detail list and the live queue read identically.
public static func title(forPath path: String) -> String {
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
if let r = t.range(of: #"^EPORNER\.COM - \[[0-9A-Za-z]+\]\s*"#, options: .regularExpression) {
t.removeSubrange(r)
}
return t.isEmpty ? "clip" : t
}
/// Every clip in a collection (not just the fresh ones), with per-file
/// freshness + last-played, fresh-first then alphabetical. Backs the detail
/// checklist so the user can see the whole collection and pick what to queue
/// or download offline. Pure over `pool` (the adult library index) + play-log.
public static func clips(pool: [String], collection: String, days: Int = defaultDays) -> [PornClip] {
guard let spec = specs.first(where: { $0.name == collection }) else { return [] }
let log = PornPlayLog.load()
let now = Date()
let inSpec = candidates(pool: pool)
.filter { inCollection(spec, basename: ($0 as NSString).lastPathComponent) }
let mapped = inSpec.map { p -> PornClip in
let base = (p as NSString).lastPathComponent
return PornClip(path: p, title: title(forPath: p),
fresh: isFresh(base, log: log, days: days, now: now),
lastPlayed: log[base].flatMap { ISO8601.date($0) })
}
return mapped.sorted { a, b in
if a.fresh != b.fresh { return a.fresh && !b.fresh }
return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending
}
}
} }
/// Per-file porn play-log (basename last-played ISO). App-owned at /// Per-file porn play-log (basename last-played ISO). App-owned at