tv-anarchy/Sources/TVAnarchy/SetupView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

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

496 lines
25 KiB
Swift

import SwiftUI
import AppKit
import UniformTypeIdentifiers
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 newTypeName = ""
@State private var newTypeAdult = false
@State private var showSkinImporter = false
@State private var skinMessage: String?
@Environment(\.winampSkin) private var winampSkin
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
appearanceSection
Divider()
#if ENABLE_ADULT
adultSection
Divider()
#endif
libraryTypesSection
Divider()
folderTypesSection
Divider()
playbackSection
Divider()
homeSection
Divider()
VPNSettingsView()
Divider()
MeshJoinView()
Divider()
librarySection
Divider()
localPlayerSection
Divider()
dependencySection
}
.padding(24)
}
.navigationTitle("Settings")
.task { await reload() }
}
// MARK: appearance
private var appearanceSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Appearance").font(.title3).bold()
Picker("Theme", selection: $library.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.label).tag(theme)
}
}
.pickerStyle(.radioGroup)
.help("Standard dark UI or classic Winamp 2.x skin for the Player")
Text(library.appTheme.help)
.font(.caption).foregroundStyle(.secondary)
if library.appTheme.usesWinampChrome {
HStack(spacing: 6) {
WinampTransportButton(symbol: "backward.fill", width: 24, height: 18) {}
WinampTransportButton(symbol: "play.fill", width: 24, height: 18) {}
WinampTransportButton(symbol: "forward.fill", width: 24, height: 18) {}
}
.themed(library.appTheme, skin: winampSkin.package)
.allowsHitTesting(false)
winampSkinSection
}
}
.themed(library.appTheme, skin: winampSkin.package)
.fileImporter(
isPresented: $showSkinImporter,
allowedContentTypes: [UTType(filenameExtension: "wsz") ?? .data],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
importWinampSkin(from: url)
case .failure(let err):
skinMessage = err.localizedDescription
}
}
}
private var winampSkinSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Winamp .wsz skin")
.font(.subheadline).bold()
if let name = library.winampSkinName, library.winampSkinId != nil {
Text("Active: \(name)")
.font(.caption)
} else {
Text("No imported skin — using built-in Winamp palette.")
.font(.caption).foregroundStyle(.secondary)
}
if let err = skinMessage ?? winampSkin.lastError {
Text(err)
.font(.caption)
.foregroundStyle(.orange)
}
HStack(spacing: 10) {
Button("Import .wsz…") { showSkinImporter = true }
.help("Import a classic Winamp 2.x .wsz skin file (extracts sprites for Player chrome)")
Button("Use Base Skin") { installBaseSkin() }
.help("Install the bundled Base 2.91 Winamp skin")
if library.winampSkinId != nil {
Button("Clear Skin") { clearWinampSkin() }
.help("Revert to the built-in Winamp palette (remove imported skin)")
}
}
.buttonStyle(.bordered)
Text("Drop any classic Winamp 2.x `.wsz` file here — TVAnarchy extracts CBUTTONS, POSBAR, VOLUME, NUMBERS, and VISCOLOR for the Player chrome.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(.top, 4)
}
private func importWinampSkin(from url: URL) {
skinMessage = nil
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
do {
let pkg = try winampSkin.install(wszURL: url)
applyWinampSkin(pkg)
skinMessage = "Installed “\(pkg.displayName)”."
} catch {
skinMessage = error.localizedDescription
}
}
private func installBaseSkin() {
skinMessage = nil
do {
let pkg = try winampSkin.installBundledBase()
applyWinampSkin(pkg)
skinMessage = "Installed bundled Base 2.91 skin."
} catch {
skinMessage = error.localizedDescription
}
}
private func applyWinampSkin(_ pkg: WinampSkinPackage) {
library.winampSkinId = pkg.id
library.winampSkinName = pkg.displayName
if !library.appTheme.usesWinampChrome {
library.appTheme = .winampClassic
}
}
private func clearWinampSkin() {
library.winampSkinId = nil
library.winampSkinName = nil
winampSkin.reload(id: nil, name: nil)
skinMessage = "Cleared imported skin."
}
// MARK: home
private var homeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Home").font(.title3).bold()
Toggle("Continue Watching", isOn: $library.showContinueWatchingOnHome)
.toggleStyle(.switch).fixedSize()
.help("Resume rail on Home — saved across launches")
Text("Resume rail at the top of Home (and the adult-only Home when enabled).")
.font(.caption).foregroundStyle(.secondary)
Toggle("Recently Added", isOn: $library.showRecentlyAddedOnHome)
.toggleStyle(.switch).fixedSize()
.help("Newest additions rail on Home — saved across launches")
Text("Newest additions rail on Home. Independent of Continue Watching — turn either off without affecting the other.")
.font(.caption).foregroundStyle(.secondary)
}
}
// MARK: playback
private var playbackSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Playback").font(.title3).bold()
Picker("Shows playback mode", selection: $library.playbackMode) {
ForEach(PlaybackMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
Text("Shows and movies on a local player: Stream fetches from the server on demand; Offline only plays cached files. Remote hosts always stream. Pick the device with the toolbar host chips — saved across launches.")
.font(.caption).foregroundStyle(.secondary)
Toggle("Notify when downloads or offline cache finish", isOn: $library.notifyDownloads)
.toggleStyle(.switch).fixedSize()
.help("Notification Center alert and in-app banner for finished or stalled transfers")
// v1 BandwidthPolicy surface (options persisted; governor actuates onto transmission upload limits).
Text("Bandwidth tiers (you > friends > public)").font(.subheadline)
Toggle("Serve friends when idle", isOn: $library.serveFriendsWhenIdle)
.toggleStyle(.switch).fixedSize()
.help("When idle (not actively fetching), prioritize bandwidth for friends' streams and custody re-pins")
Toggle("Seed public when idle", isOn: $library.seedPublicWhenIdle)
.toggleStyle(.switch).fixedSize()
.help("When idle and friends toggled off, seed public swarms with leftover upload")
Text("Total upload (KB/s, 0=unmetered): \(library.totalUploadKBps ?? 0)")
.font(.caption)
Stepper("Cap", value: Binding(get: { Double(library.totalUploadKBps ?? 0) }, set: { library.totalUploadKBps = Int($0) }), in: 0...20000, step: 100)
.fixedSize()
.help("Global upload rate cap passed to governor for bandwidth policy (0 = unlimited)")
Text("Notification Center alert + in-app banner when a torrent finishes or stalls, or when offline warmup completes.")
.font(.caption).foregroundStyle(.secondary)
Stepper("Skip intro: \(library.skipIntroSeconds == 0 ? "off" : "\(library.skipIntroSeconds)s")",
value: $library.skipIntroSeconds, in: 0...180, step: 15)
.fixedSize()
.help("Skip Intro on the Player page seeks to this offset — 0 hides the button")
Text("Skip Intro on the Player page seeks to this offset. Set to 0 to hide the button.")
.font(.caption).foregroundStyle(.secondary)
}
}
// 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)
.help("Show adult category and items on the main Home — independent of the Library browse toggle")
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)
.help("Adult tab gets Continue Watching and Recently Added rails above collections")
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)
Picker("Adult playback mode", selection: $library.adultPlaybackMode) {
ForEach(PlaybackMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented).disabled(!library.pornFeature)
.help("Playback mode (stream vs offline cache) only for adult content — independent of the main shows mode")
Text("Independent of shows sourcing — applies when adult content plays on a local player. Pick the device with the host chips in the toolbar.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
Stepper("Queue \(library.adultQueueCount) clips per collection tap",
value: $library.adultQueueCount, in: 5...100, step: 5)
.fixedSize().disabled(!library.pornFeature)
.help("Number of preview clips to queue when tapping an Adult collection")
Text("How many fresh clips each Adult collection tap queues.")
.font(.caption).foregroundStyle(.secondary)
}
}
#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)
.help("Restore the built-in type catalog (TV, Anime, Movies, Porn, …)")
}
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()
.help("Adult types are gated behind the sidebar eye icon")
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()
.help("Mark the new type as adult content")
Button("Add") {
library.addLibraryType(name: newTypeName, adult: newTypeAdult)
newTypeName = ""; newTypeAdult = false
}
.disabled(newTypeName.trimmingCharacters(in: .whitespaces).isEmpty)
.help("Add a new custom library type (can be marked adult)")
}
.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()
.help("Classify this folder — saved in settings.json; Porn marks it adult without renaming on disk")
}
}
}
}
}
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)
.help("Walk the media library on black and refresh the cached index TVAnarchy reads")
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: $library.hoverPreviews)
.toggleStyle(.switch).fixedSize()
.help("Muted preview clip on poster hover — first hover per show builds on black via ffmpeg")
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: $library.combineSplitShows)
.toggleStyle(.switch).fixedSize()
.help("Merge duplicate library folders of one show — decisions cached on disk")
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: $library.useLLMGrouper)
.toggleStyle(.switch).fixedSize()
.disabled(!library.combineSplitShows)
.help("Optional MLX model for ambiguous same-year clusters — off uses the deterministic matcher")
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()
.help("Which app plays video on this Mac — saved in devices.json")
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)
.help("Re-check which dependencies are installed")
}
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: " && ")
}
}