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: " && ") } }