tv-anarchy/Sources/TVAnarchy/SetupView.swift

222 lines
11 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()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
homeSection
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.
.onChange(of: settings) { _, new in SettingsStore.save(new) }
}
// 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: home
private var homeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Home screen").font(.title3).bold()
Toggle("Show adult content on Home", isOn: $library.surfaceAdultOnHome)
.toggleStyle(.switch).fixedSize()
Text(library.surfaceAdultOnHome
? "The porn category and adult items in Continue Watching / Recently Added appear on Home."
: "Home hides the porn category and keeps adult titles out of Continue Watching and Recently Added. You can still browse it in the Library tab.")
.font(.caption).foregroundStyle(.secondary)
}
}
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: " && ")
}
}