tv-anarchy/Sources/TVAnarchy/PlaylistPopover.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

195 lines
8.2 KiB
Swift

import SwiftUI
import TVAnarchyCore
/// The play-queue panel shown from the global sidebar button. Top: one-tap auto
/// generators + a per-category shuffle. Middle: the current queue (reorder /
/// delete). Bottom: clear + fire-to-active-host. Building a queue here never
/// touches a host until "Play" it just assembles paths, then enqueues them.
struct PlaylistPopover: View {
@Bindable var playlist: PlaylistController
@Bindable var player: PlayerController
@Bindable var library: LibraryController
@State private var editing: SmartPlaylist?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Play Queue").font(.headline)
// Auto generators
HStack(spacing: 8) {
ForEach(AutoPlaylist.allCases, id: \.self) { gen in
Button { playlist.generate(gen) } label: {
Label(gen.label, systemImage: gen.icon).labelStyle(.titleAndIcon)
}
.buttonStyle(.bordered).controlSize(.small)
}
}
Menu {
ForEach(library.homeCategories, id: \.self) { cat in
Button(LibraryConfig.label(cat)) { playlist.generateShuffle(category: cat) }
}
} label: { Label("Shuffle a category…", systemImage: "square.stack.3d.up") }
.menuStyle(.borderlessButton).controlSize(.small).fixedSize()
smartSection
#if ENABLE_ADULT
if library.pornFeature { pornSection }
#endif
Divider()
// The queue
if playlist.isEmpty {
Text("Queue is empty — use a generator above, or add titles from the Library.")
.font(.callout).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 120, alignment: .center)
.multilineTextAlignment(.center)
} else {
List {
ForEach(playlist.queue) { item in
HStack(spacing: 8) {
Image(systemName: "line.3.horizontal").foregroundStyle(.tertiary)
Text(item.title).lineLimit(1)
}
}
.onMove { playlist.move(fromOffsets: $0, toOffset: $1) }
.onDelete { playlist.remove(atOffsets: $0) }
}
.frame(height: 240)
.listStyle(.inset)
}
Divider()
HStack {
Button("Clear", role: .destructive) { playlist.clear() }
.disabled(playlist.isEmpty)
Spacer()
Button {
playlist.play(on: player)
} label: {
Label("Play \(playlist.count) on \(player.active?.name ?? "host")",
systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(playlist.isEmpty)
}
}
.padding(16)
.frame(width: 380)
.sheet(item: $editing) { rule in
SmartPlaylistEditor(rule: rule, categories: library.homeCategories) { saved in
playlist.upsert(saved); editing = nil
} onCancel: { editing = nil }
}
.task {
#if ENABLE_ADULT
// Only compute the collections when the adult feature is enabled.
if library.pornFeature, playlist.pornCollections.isEmpty {
await playlist.loadPornCollections()
}
#endif
}
}
#if ENABLE_ADULT
// Native porn collections (freshness-aware, sourced from the library index).
// Tapping one loads its fresh files as the queue (and marks them played).
// Gated by the adult feature switch, so it never appears unless adult content
// is enabled and compiled out entirely without `ENABLE_ADULT`.
private var pornSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Collections").font(.subheadline).bold()
if playlist.pornCollections.isEmpty {
Text("None available (script or media mount offline).")
.font(.caption).foregroundStyle(.secondary)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 6)], spacing: 6) {
ForEach(playlist.pornCollections) { col in
Button { Task { await playlist.applyPornCollection(col.name) } } label: {
VStack(spacing: 1) {
Text(col.name).font(.caption).lineLimit(1)
Text("\(col.fresh) fresh").font(.caption2).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered).controlSize(.small)
.disabled(col.fresh == 0)
.help(col.desc)
}
}
}
}
}
#endif
// Saved rule-based playlists: tap to load into the queue, edit, or delete.
private var smartSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Smart playlists").font(.subheadline).bold()
Spacer()
Button { editing = SmartPlaylist(name: "New playlist") } label: {
Image(systemName: "plus")
}.buttonStyle(.borderless).controlSize(.small).help("New smart playlist")
}
if playlist.smartPlaylists.isEmpty {
Text("None yet — “+” saves a reusable rule (category · unwatched · shuffle · limit).")
.font(.caption).foregroundStyle(.secondary)
} else {
ForEach(playlist.smartPlaylists) { rule in
HStack(spacing: 8) {
Button { playlist.apply(rule) } label: {
VStack(alignment: .leading, spacing: 1) {
Text(rule.name).font(.callout)
Text(rule.summary).font(.caption2).foregroundStyle(.secondary)
}
}.buttonStyle(.plain)
Spacer()
Button { editing = rule } label: { Image(systemName: "pencil") }
.buttonStyle(.borderless).controlSize(.small)
Button(role: .destructive) { playlist.deleteSmart(id: rule.id) } label: {
Image(systemName: "trash")
}.buttonStyle(.borderless).controlSize(.small)
}
}
}
}
}
}
/// Compact create/edit form for a saved rule.
private struct SmartPlaylistEditor: View {
@State var rule: SmartPlaylist
let categories: [String]
let onSave: (SmartPlaylist) -> Void
let onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Smart playlist").font(.headline)
Form {
TextField("Name", text: $rule.name)
Picker("Category", selection: Binding(
get: { rule.category ?? "" },
set: { rule.category = $0.isEmpty ? nil : $0 }
)) {
Text("All").tag("")
ForEach(categories, id: \.self) { Text(LibraryConfig.label($0)).tag($0) }
}
Toggle("Unwatched only", isOn: $rule.unwatchedOnly)
Toggle("Shuffle (off = newest first)", isOn: $rule.shuffle)
Stepper("Limit: \(rule.limit)", value: $rule.limit, in: 1...200, step: 5)
}
HStack {
Button("Cancel", role: .cancel) { onCancel() }
Spacer()
Button("Save") { onSave(rule) }
.buttonStyle(.borderedProminent)
.disabled(rule.name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding(18).frame(width: 320)
}
}