339 lines
17 KiB
Swift
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: " && ")
|
|
}
|
|
}
|