diff --git a/Sources/TVAnarchy/AdultView.swift b/Sources/TVAnarchy/AdultView.swift index 4ee2b5f..5aa835d 100644 --- a/Sources/TVAnarchy/AdultView.swift +++ b/Sources/TVAnarchy/AdultView.swift @@ -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 { diff --git a/Sources/TVAnarchy/GoonCollectionView.swift b/Sources/TVAnarchy/GoonCollectionView.swift new file mode 100644 index 0000000..cfee6a0 --- /dev/null +++ b/Sources/TVAnarchy/GoonCollectionView.swift @@ -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 = [] + /// Paths confirmed present in the offline cache (seeded from the index, then + /// updated as downloads complete). + @State private var offlinePaths: Set = [] + + 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 diff --git a/Sources/TVAnarchyCore/PlaylistController.swift b/Sources/TVAnarchyCore/PlaylistController.swift index 221aee2..6addd47 100644 --- a/Sources/TVAnarchyCore/PlaylistController.swift +++ b/Sources/TVAnarchyCore/PlaylistController.swift @@ -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 diff --git a/Sources/TVAnarchyCore/PornCollectionService.swift b/Sources/TVAnarchyCore/PornCollectionService.swift index 07f6c63..74d2f43 100644 --- a/Sources/TVAnarchyCore/PornCollectionService.swift +++ b/Sources/TVAnarchyCore/PornCollectionService.swift @@ -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