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

339 lines
17 KiB
Swift

import SwiftUI
import AppKit
import TVAnarchyCore
/// First-run / setup pane: shows which external dependencies are present (and the
/// Homebrew command to install the missing ones), and lets the user pick the
/// local player TVAnarchy plays into. Detection is read-only; installing is the
/// user's action (we surface + copy the command rather than running brew for them).
struct SetupView: View {
@Bindable var controller: PlayerController
@Bindable var library: LibraryController
@State private var deps: [Dependency] = []
@State private var loading = true
@State private var settings = SettingsStore.load()
@State private var newTypeName = ""
@State private var newTypeAdult = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
#if ENABLE_ADULT
adultSection
Divider()
#endif
libraryTypesSection
Divider()
folderTypesSection
Divider()
playbackSection
Divider()
offlineSection
Divider()
VPNSettingsView()
Divider()
librarySection
Divider()
localPlayerSection
Divider()
dependencySection
}
.padding(24)
}
.navigationTitle("Settings")
.task { await reload() }
// One persist for any change AppSettings is Equatable. Merge-save: only
// the fields THIS view binds are applied onto a fresh disk read, so the
// (session-stale) local copy can never clobber fields written elsewhere
// through LibraryController (adult switches, folder/type config).
.onChange(of: settings) { _, new in
var s = SettingsStore.load()
s.forwardMediaKeys = new.forwardMediaKeys
s.notifyDownloads = new.notifyDownloads
s.offlineEpisodes = new.offlineEpisodes
s.offlineShows = new.offlineShows
s.offlineFromContinueWatching = new.offlineFromContinueWatching
s.hoverPreviews = new.hoverPreviews
s.combineSplitShows = new.combineSplitShows
s.useLLMGrouper = new.useLLMGrouper
SettingsStore.save(s)
}
}
// MARK: playback
private var playbackSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Playback").font(.title3).bold()
Toggle("Forward this Mac's media keys", isOn: $settings.forwardMediaKeys)
.toggleStyle(.switch).fixedSize()
.onChange(of: settings.forwardMediaKeys) { controller.applyMediaKeyForwarding() }
Text("The keyboard media keys, Control Center, lock screen and AirPods drive whatever TVAnarchy is playing, and Now Playing shows the title. Off lets the keys fall through to other apps.")
.font(.caption).foregroundStyle(.secondary)
Toggle("Notify when a download is ready or stuck", isOn: $settings.notifyDownloads)
.toggleStyle(.switch).fixedSize()
Text("A Notification Center alert + an in-app banner when a download finishes (ready to watch) or stalls (0 peers).")
.font(.caption).foregroundStyle(.secondary)
}
}
// MARK: offline cache
private var offlineSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Offline cache").font(.title3).bold()
Text("For cellphone/laptop devices: pull the next episodes of your recent shows to local disk (rsync from black) so they play off-LAN.")
.font(.caption).foregroundStyle(.secondary)
Stepper("Episodes per show: \(settings.offlineEpisodes)", value: $settings.offlineEpisodes, in: 1...10)
.fixedSize()
Stepper("Recent shows: \(settings.offlineShows)", value: $settings.offlineShows, in: 1...20)
.fixedSize()
Picker("Pick shows from", selection: $settings.offlineFromContinueWatching) {
Text("Continue Watching").tag(true)
Text("Recently Added").tag(false)
}.pickerStyle(.segmented).fixedSize()
}
}
// MARK: adult
#if ENABLE_ADULT
/// Adult-content options. The master switch is the discreet eye icon at the
/// bottom of the sidebar (not duplicated here, to keep one source of truth);
/// these two sub-options only apply while it's on and are disabled otherwise.
private var adultSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Adult content").font(.title3).bold()
Text(library.pornFeature
? "Enabled. Toggle it off with the eye icon at the bottom of the sidebar."
: "Hidden. Click the eye icon at the bottom of the sidebar to reveal the Adult tab.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Toggle("Blend adult content into Home", isOn: $library.surfaceAdultOnHome)
.toggleStyle(.switch).fixedSize().disabled(!library.pornFeature)
Text("Show the porn category and adult items in the main Home's Continue Watching / Recently Added rails.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
Toggle("Adult tab as its own Home", isOn: $library.switchToAdultOnlyHome)
.toggleStyle(.switch).fixedSize().disabled(!library.pornFeature)
Text("Make the Adult tab a full adult-only landing — Continue Watching and Recently Added rails above the collections — instead of just the collections browser.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}
}
#endif
// MARK: library type catalog (editable / expandable)
/// Edit the catalog of library types a folder can be: rename, toggle adult,
/// add custom types, remove unused ones. The default catalog ships seeded;
/// "Reset" restores it.
private var libraryTypesSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Library types").font(.title3).bold()
Spacer()
Button("Reset to defaults") { library.resetLibraryTypes() }.controlSize(.small)
}
Text("The set of types a folder can be assigned. Adult types (Porn by default) are hidden/gated everywhere.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
ForEach(library.libraryTypes) { t in
HStack(spacing: 8) {
TextField("Label", text: Binding(
get: { t.label },
set: { library.updateLibraryType(t.id, label: $0, adult: t.adult) }
))
.textFieldStyle(.roundedBorder).frame(width: 150)
Text(t.id).font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1)
Spacer()
Toggle("Adult", isOn: Binding(
get: { t.adult },
set: { library.updateLibraryType(t.id, label: t.label, adult: $0) }
)).toggleStyle(.switch).controlSize(.mini).fixedSize()
Button(role: .destructive) { library.removeLibraryType(t.id) } label: {
Image(systemName: "trash")
}.buttonStyle(.borderless).help("Remove this type")
}
}
HStack(spacing: 8) {
TextField("Add a type…", text: $newTypeName).textFieldStyle(.roundedBorder).frame(width: 150)
Toggle("Adult", isOn: $newTypeAdult).toggleStyle(.switch).controlSize(.mini).fixedSize()
Button("Add") {
library.addLibraryType(name: newTypeName, adult: newTypeAdult)
newTypeName = ""; newTypeAdult = false
}
.disabled(newTypeName.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 2)
}
}
// MARK: library folder types
/// Assign each top-level media folder a library type. Re-typing folds a folder
/// into another type's rails (e.g. an oddly-named folder Movies); setting it
/// to Porn marks it adult all without renaming anything on disk.
private var folderTypesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Library folder types").font(.title3).bold()
Text("Each top-level media folder is a library type. Change a folder's type to group it with that type everywhere; set it to Porn to treat it as adult. Defaults to the folder's own name — no on-disk renaming.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
if library.presentFolders.isEmpty {
Text("No folders yet — rebuild the index once the media mount is reachable.")
.font(.caption).foregroundStyle(.tertiary)
} else {
ForEach(library.presentFolders, id: \.self) { folder in
HStack {
Text(folder).font(.callout).lineLimit(1)
Spacer()
Picker("", selection: Binding(
get: { library.type(of: folder) },
set: { library.setFolderType(folder, $0) }
)) {
ForEach(library.libraryTypes) { t in Text(t.label).tag(t.id) }
// Folders whose name isn't itself a configured type get
// an "as-is" option so the picker has a matching tag for
// the identity (unmapped) value.
if library.libraryTypes.first(where: { $0.id == folder }) == nil {
Divider()
Text("As-is (\(folder))").tag(folder)
}
}
.labelsHidden().fixedSize()
}
}
}
}
}
private func reload() async {
loading = true
deps = await DepsService.detect()
loading = false
}
// MARK: library index
private var librarySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Library index").font(.title3).bold()
Text("black builds a fast index of the media library out-of-band; TVAnarchy reads it instantly instead of walking the network share (minutes over NFS). It refreshes automatically when a download finishes — rebuild manually if you added media on black directly.")
.font(.caption).foregroundStyle(.secondary)
Button { library.rebuildIndex() } label: {
Label(library.rebuildingIndex ? "Rebuilding on black…" : "Rebuild full index (overscan)",
systemImage: "arrow.triangle.2.circlepath")
}
.disabled(library.rebuildingIndex)
if library.rebuildingIndex {
ScanningBanner(progress: library.scanProgress, total: library.scanTotal, label: "Indexing on black")
}
if let last = library.lastRefresh {
Text("\(library.shows.count) items · loaded \(last.formatted(date: .abbreviated, time: .shortened)) · source: \(library.source)")
.font(.caption2).foregroundStyle(.tertiary)
}
Divider().padding(.vertical, 2)
Toggle("Hover previews", isOn: $settings.hoverPreviews)
.toggleStyle(.switch).fixedSize()
Text("Play a short muted clip (seeked past the intro) when you hover a poster. Clips are built on black with ffmpeg the first time you hover, then cached — so the first play of each show lags a few seconds.")
.font(.caption).foregroundStyle(.secondary)
Divider().padding(.vertical, 2)
Toggle("Combine split/duplicate shows", isOn: $settings.combineSplitShows)
.toggleStyle(.switch).fixedSize()
Text("Merge separate folders/releases of one show into a single entry (e.g. Dandadan's duplicates). A fast deterministic matcher handles it; decisions are cached.")
.font(.caption).foregroundStyle(.secondary)
Toggle("Use the local LLM for hard cases", isOn: $settings.useLLMGrouper)
.toggleStyle(.switch).fixedSize()
.disabled(!settings.combineSplitShows)
Text("For ambiguous clusters only, ask the on-device MLX model (a separate download) to judge same-work vs different-work. Off by default.")
.font(.caption).foregroundStyle(.secondary)
}
}
// MARK: local player
private var localPlayerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Local player").font(.title3).bold()
Text("Which app plays video on this Mac. Remote hosts (black) are unaffected.")
.font(.caption).foregroundStyle(.secondary)
Picker("Local player", selection: Binding(
get: { controller.localPlayerKind ?? .quicktime },
set: { controller.setLocalPlayer($0) }
)) {
Text("QuickTime").tag(HostKind.quicktime)
Text("VLC").tag(HostKind.vlc)
}
.pickerStyle(.segmented).fixedSize()
Text(controller.localPlayerKind == .vlc
? "VLC: full control (play/seek/volume) over its HTTP interface. Needs VLC installed + its web interface enabled."
: "QuickTime: built into macOS, no install. Play/pause, seek and volume work; no playlist or quality switching.")
.font(.caption).foregroundStyle(.secondary)
}
}
// MARK: dependencies
private var dependencySection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Dependencies").font(.title3).bold()
if loading { ProgressView().controlSize(.small) }
Spacer()
Button { Task { await reload() } } label: { Image(systemName: "arrow.clockwise") }
.disabled(loading)
}
if !loading {
Text(readiness).font(.caption).foregroundStyle(missingRequired ? .orange : .green)
}
ForEach(deps) { dep in depRow(dep) }
if missingAny {
Text("Install everything missing:").font(.caption).foregroundStyle(.secondary).padding(.top, 4)
copyableCommand(installAllCommand)
}
}
}
private func depRow(_ dep: Dependency) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: dep.found ? "checkmark.circle.fill" : (dep.need == .required ? "xmark.circle.fill" : "circle.dashed"))
.foregroundStyle(dep.found ? .green : (dep.need == .required ? .red : .secondary))
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(dep.name).font(.callout).bold()
if dep.need == .optional { Text("optional").font(.caption2).foregroundStyle(.secondary) }
}
Text(dep.purpose).font(.caption).foregroundStyle(.secondary)
Text(dep.detail).font(.caption2.monospaced()).foregroundStyle(dep.found ? Color.secondary : Color.orange)
if !dep.found {
copyableCommand(dep.installHint)
}
}
Spacer()
}
.padding(.vertical, 3)
}
private func copyableCommand(_ cmd: String) -> some View {
HStack(spacing: 8) {
Text(cmd).font(.caption.monospaced()).textSelection(.enabled)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
Button { copy(cmd) } label: { Image(systemName: "doc.on.doc") }
.buttonStyle(.borderless).help("Copy")
}
}
private func copy(_ s: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(s, forType: .string)
}
private var missingRequired: Bool { deps.contains { !$0.found && $0.need == .required } }
private var missingAny: Bool { deps.contains { !$0.found } }
private var readiness: String {
missingRequired ? "Some required tools are missing — install them below."
: "All required tools present."
}
private var installAllCommand: String {
let cmds = deps.filter { !$0.found && $0.installHint.hasPrefix("brew") }.map(\.installHint)
return cmds.isEmpty ? "(nothing to install)" : Array(Set(cmds)).sorted().joined(separator: " && ")
}
}