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:
parent
4a2ceb9781
commit
ee7efad888
4 changed files with 372 additions and 5 deletions
|
|
@ -20,6 +20,8 @@ struct AdultView: View {
|
|||
@State private var loading = false
|
||||
/// The collection currently being fired (spinner + disable), nil when idle.
|
||||
@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)]
|
||||
|
||||
|
|
@ -60,6 +62,9 @@ struct AdultView: View {
|
|||
}
|
||||
}
|
||||
.animation(.default, value: player.actionMessage)
|
||||
.sheet(item: $detail) { col in
|
||||
GoonCollectionView(collection: col, playlist: playlist, player: player, library: library)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ text: String) {
|
||||
|
|
@ -145,7 +150,7 @@ struct AdultView: View {
|
|||
}
|
||||
|
||||
private func collectionCard(_ col: PornCollection) -> some View {
|
||||
Button { Task { await play(col) } } label: {
|
||||
Button { detail = col } label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(col.name.capitalized).font(.headline)
|
||||
|
|
@ -167,10 +172,16 @@ struct AdultView: View {
|
|||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(col.fresh == 0 || firing != nil)
|
||||
.help(col.fresh == 0
|
||||
? "Nothing fresh — everything here was played in the last \(PornCollectionService.defaultDays) days"
|
||||
: "Play \(min(library.adultQueueCount, col.fresh)) fresh clip\(min(library.adultQueueCount, col.fresh) == 1 ? "" : "s") on \(selectedHostName)")
|
||||
.disabled(firing != nil)
|
||||
.help("Open \(col.name.capitalized) — see all \(col.total) clips, tick what you want, download to offline or play")
|
||||
.contextMenu {
|
||||
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 {
|
||||
|
|
|
|||
280
Sources/TVAnarchy/GoonCollectionView.swift
Normal file
280
Sources/TVAnarchy/GoonCollectionView.swift
Normal 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("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
|
||||
|
|
@ -161,6 +161,26 @@ public final class PlaylistController {
|
|||
}
|
||||
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)
|
||||
|
||||
/// Replace the queue from a one-tap generator. `limit` caps the queue length.
|
||||
|
|
@ -326,6 +346,15 @@ public final class PlaylistController {
|
|||
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.
|
||||
static func prettyPornTitle(_ path: String) -> String {
|
||||
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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.
|
||||
|
|
@ -193,6 +207,39 @@ public enum PornCollectionService {
|
|||
for p in paths { log[(p as NSString).lastPathComponent] = stamp }
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue