tv-anarchy/Sources/TVAnarchy/AdultView.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

269 lines
12 KiB
Swift

#if ENABLE_ADULT
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
import TVAnarchyCore
/// The dedicated adult browse surface (the "Adult" tab). Always lists the native
/// porn collections (freshness-aware, sourced from the library index). When
/// `switchToAdultOnlyHome` is on it becomes a full adult-only Home: Continue
/// Watching + Recently Added rails (adult-only) above the collections grid,
/// mirroring the main Home but filtered to adult content. Gated by `ENABLE_ADULT`
/// at compile time and `pornFeature` at runtime, so it never appears unless the
/// feature is switched on (the sidebar eye icon).
struct AdultView: View {
@Bindable var playlist: PlaylistController
@Bindable var player: PlayerController
@Bindable var library: LibraryController
@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)]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
resumePlaylistCard
if library.switchToAdultOnlyHome { adultHomeRails }
collectionsSection
}
.padding(24)
}
.navigationTitle("Adult")
.toolbar { ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true, showShowsMode: false) } }
.task { await reload() }
// Keep the adult-only-Home rails live while that mode is on (mirrors the
// main Home's loop, which only runs while Home is open). Re-runs when the
// flag flips so turning it on mid-view starts refreshing immediately.
.task(id: library.switchToAdultOnlyHome) {
guard library.switchToAdultOnlyHome else { return }
await library.refreshIfStale()
while !Task.isCancelled {
library.refreshContinueWatching()
try? await Task.sleep(for: .seconds(8))
}
}
.overlay(alignment: .bottom) {
if let msg = player.actionMessage {
Text(msg).font(.callout)
.padding(.horizontal, 14).padding(.vertical, 10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
.padding(.bottom, 16)
.onTapGesture { copyToClipboard(msg) }.help("Click to copy")
.task(id: msg) {
let secs = msg.contains("not downloaded") ? 12.0 : 4.0
try? await Task.sleep(for: .seconds(secs))
player.note(nil)
}
}
}
.animation(.default, value: player.actionMessage)
.sheet(item: $detail) { col in
GoonCollectionView(collection: col, playlist: playlist, player: player, library: library)
}
}
private func copyToClipboard(_ text: String) {
#if canImport(AppKit)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
}
// MARK: continue watching last adult playlist
/// "Continue Watching" the last adult playlist: resumes the same shuffled
/// collection queue on the active host, picking up at the first clip you hadn't
/// finished. The adult counterpart to the main Home's recovery banner its own
/// persisted lane, so it survives relaunch and never surfaces on the main Home.
@ViewBuilder private var resumePlaylistCard: some View {
if let snap = playlist.lastAdultPlaylist {
Button { playlist.resumeAdultPlaylist(on: player) } label: {
HStack(spacing: 14) {
Image(systemName: "play.circle.fill")
.font(.system(size: 34)).foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text("Continue Watching").font(.caption).foregroundStyle(.secondary)
Text(snap.label).font(.headline)
Text("Resume your last playlist · \(snap.count) clips")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { playlist.clearAdultPlaylist() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Forget this playlist")
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.help("Resume “\(snap.label)” on \(selectedHostName)")
}
}
// MARK: adult-only Home rails
@ViewBuilder private var adultHomeRails: some View {
if library.showContinueWatchingOnHome {
let continuing = library.adultContinueWatching
if !continuing.isEmpty {
rail("Continue Watching") {
ForEach(continuing) { item in
Button { play(continue: item) } label: { ContinueCard(item: item) }
.buttonStyle(.plain)
}
}
}
}
if library.showRecentlyAddedOnHome {
let recent = library.adultRecentlyAdded()
if !recent.isEmpty {
rail("Recently Added") {
ForEach(recent) { show in
Button { play(show: show) } label: { ShowPoster(show: show, width: 132) }
.buttonStyle(.plain)
}
}
}
}
}
private func rail<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.title3).bold()
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) { content() }.padding(.bottom, 4)
}
}
}
// MARK: collections
@ViewBuilder private var collectionsSection: some View {
collectionsHeader
if playlist.pornCollections.allSatisfy({ $0.total == 0 }) {
emptyState
} else {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(playlist.pornCollections) { col in collectionCard(col) }
}
}
}
private var collectionsHeader: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Collections").font(.title3).bold()
Text("Freshness-aware rotation over the adult library: each tap queues a fresh, shuffled set you haven't seen in \(PornCollectionService.defaultDays) days and plays on the selected host (\(selectedHostName)). Stream/Offline below controls whether a local player fetches on demand or requires cached files. Counts and freshness are tracked per file, so nothing replays across collections.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 14) {
Picker("Adult sourcing", selection: $library.adultPlaybackMode) {
ForEach(PlaybackMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 148)
.help("On a local player: Stream fetches from the server; Offline requires cached files. Remote hosts always stream.")
Stepper("Queue \(library.adultQueueCount) clips",
value: $library.adultQueueCount, in: 5...100, step: 5)
.fixedSize()
.help("How many fresh clips each collection tap queues — saved in settings.json")
Button { Task { await reload() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }
.controlSize(.small).disabled(loading)
if loading { ProgressView().controlSize(.small) }
}
}
}
private func collectionCard(_ col: PornCollection) -> some View {
Button { detail = col } label: {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(col.name.capitalized).font(.headline)
Spacer()
if firing == col.name { ProgressView().controlSize(.small) }
}
Text(col.desc).font(.caption).foregroundStyle(.secondary)
.lineLimit(2).fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
HStack(spacing: 4) {
Image(systemName: "sparkles").font(.caption2)
Text("\(col.fresh) fresh").font(.caption).bold()
Text("/ \(col.total)").font(.caption).foregroundStyle(.tertiary)
}
.foregroundStyle(col.fresh == 0 ? Color.secondary : Color.accentColor)
}
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.padding(12)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
.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 {
VStack(spacing: 10) {
Image(systemName: "film.stack").font(.largeTitle).foregroundStyle(.tertiary)
Text("No adult content in the library index.")
.font(.callout).foregroundStyle(.secondary)
Text("Rebuild the index in Setup once the media mount is reachable, or add titles via Downloads.")
.font(.caption).foregroundStyle(.tertiary).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, minHeight: 220)
}
private var selectedHostName: String {
player.active?.name ?? "selected host"
}
// MARK: actions
private func reload() async {
loading = true
await playlist.loadPornCollections()
loading = false
}
/// Load a fresh shuffle of the collection as the queue and fire it on the
/// active host. `applyPornCollection` marks the clips played and refreshes the
/// counts, so the card's "fresh" number drops immediately after.
private func play(_ col: PornCollection) async {
firing = col.name
await playlist.applyPornCollection(col.name, count: library.adultQueueCount)
playlist.play(on: player)
firing = nil
}
private func play(continue item: ContinueItem) {
guard let kind = player.activeKind,
let req = library.launchRequest(continue: item, targetKind: kind) else {
player.note("No player selected"); return
}
player.launch(req, series: item.show, resumeSeconds: item.positionSeconds, adult: true)
}
private func play(show: CachedShow) {
guard let kind = player.activeKind,
let req = library.launchRequest(show: show, episode: nil, targetKind: kind) else {
player.note("No player selected"); return
}
player.launch(req, series: show.kind == .series ? show.name : nil, category: show.category,
adult: true)
}
}
#endif