#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 = [] /// Probed clip durations (seconds), filled lazily in the background per path. @State private var durations: [String: Double] = [:] /// Re-keys the duration probe: changes when clips load or the filter settles. private var probeKey: String { "\(clips.count)#\(filter)" } 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) } } /// Total known runtime of the queued clips (sum of probed durations). private var queuedSeconds: Double { queued.compactMap { durations[$0.path] }.reduce(0, +) } var body: some View { VStack(spacing: 0) { header Divider() content } .frame(minWidth: 580, minHeight: 540) .task { await load() } .task(id: probeKey) { await probeVisible() } } // 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 { selectAllShown() } label: { Label("All", systemImage: "checkmark.circle") } .controlSize(.small) .disabled(shown.isEmpty || shown.allSatisfy { playlist.isQueued(path: $0.path) }) .help(filter.isEmpty ? "Select every clip" : "Select every clip matching the filter") Button { selectNoneShown() } label: { Label("None", systemImage: "circle") } .controlSize(.small) .disabled(shown.allSatisfy { !playlist.isQueued(path: $0.path) }) .help(filter.isEmpty ? "Deselect every clip" : "Deselect every clip matching the filter") 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() if queuedSeconds > 0 { Text(DurationProbe.format(queuedSeconds)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) .help("Total runtime of queued clips") } 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 let secs = durations[clip.path] { Label(DurationProbe.format(secs), systemImage: "clock") .font(.caption2.monospacedDigit()).foregroundStyle(.secondary) } 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 } /// Fill durations for the currently-shown clips in one background SSH batch. /// Debounced so fast filter typing doesn't spawn an ssh per keystroke; capped /// so the "all" collection (hundreds of clips) can't launch an unbounded walk. private func probeVisible() async { try? await Task.sleep(for: .milliseconds(350)) if Task.isCancelled { return } let need = Array(Set(shown.map(\.path)).subtracting(durations.keys)).prefix(400) guard !need.isEmpty else { return } let targets = Array(need) let probed = await Task.detached(priority: .utility) { DurationProbe.probe(paths: targets) }.value if Task.isCancelled || probed.isEmpty { return } durations.merge(probed) { _, new in new } } 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) } } /// Queue every currently-shown clip (respects the active filter, so "All" while /// filtered selects just the matches). private func selectAllShown() { for c in shown where !playlist.isQueued(path: c.path) { playlist.addToQueue(id: c.path, title: c.title, path: c.path) } } /// Deselect every currently-shown clip (respects the active filter). private func selectNoneShown() { for c in shown where playlist.isQueued(path: c.path) { playlist.removeFromQueue(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.noteAdultPlaylistLabel(collection.name.capitalized) 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