195 lines
8.2 KiB
Swift
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)
|
|
}
|
|
}
|