tv-anarchy/Sources/TVAnarchy/GoonCollectionView.swift
Natalie d793d54dfb feat(adult): Continue Watching last adult playlist + separate adult/non-adult playlist lanes
The Adult Home now mirrors the main Home's resume affordance: the last adult
collection playlist that was fired is persisted to its own lane and surfaced as
a "Continue Watching" card that re-queues it on the active host, skipping clips
already finished and resuming the first unwatched one at its saved position.

Separation: adult playlists get a dedicated AdultPlaylistStore
(last-adult-playlist.json), distinct from the adult-stripped non-adult
QueueStore (play-queue.json), so the two lanes never bleed together. The main
Home's interrupt-recovery banner is filtered to non-adult snapshots, keeping
adult titles off the regular Home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 03:28:12 -04:00

345 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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> = []
/// 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("Couldnt download \(clip.title)")
}
}
private func downloadQueued() async {
let targets = queued.filter { !offlinePaths.contains($0.path) }
for c in targets { await download(c) }
}
}
#endif