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

246 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import TVAnarchyCore
/// Add/edit a device: its role (`type`, which presets services), its overridable
/// `services`, and its player backend. Flat fields a `DeviceConfig` on save.
/// Kind-specific sections appear for the chosen backend (VLC / mpv-SSH / QuickTime).
struct DeviceEditView: View {
let existing: DeviceConfig?
let onSave: (DeviceConfig) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var hostname: String
@State private var type: DeviceType
@State private var services: DeviceServices
@State private var kind: HostKind
// VLC
@State private var vlcHost: String
@State private var vlcPort: String
// mpv-ipc
@State private var endpoints: String
@State private var socket: String
@State private var sudo: Bool
@State private var socat: String
@State private var volumeScale: String
@State private var helperBin: String
// roku (ECP)
@State private var rokuHost: String
@State private var rokuPort: String
@State private var offlinePolicy: OfflineCachePolicy
@State private var streamPolicy: StreamPolicy
init(existing: DeviceConfig?, onSave: @escaping (DeviceConfig) -> Void) {
self.existing = existing
self.onSave = onSave
let t = existing?.type ?? .laptop
_name = State(initialValue: existing?.name ?? "")
_hostname = State(initialValue: existing?.hostname ?? "")
_type = State(initialValue: t)
_services = State(initialValue: existing?.services ?? t.defaultServices)
_kind = State(initialValue: existing?.kind == .blacktv ? .mpvIPC : (existing?.kind ?? .mpvIPC))
_vlcHost = State(initialValue: existing?.vlc?.host ?? "127.0.0.1")
_vlcPort = State(initialValue: existing?.vlc.map { String($0.port) } ?? "8080")
let stored = existing?.mpv?.endpoints ?? existing?.ssh?.endpoints ?? []
_endpoints = State(initialValue: stored.isEmpty ? "" : stored.joined(separator: ", "))
_socket = State(initialValue: existing?.mpv?.socket ?? "/tmp/mpv.sock")
_sudo = State(initialValue: existing?.mpv?.sudo ?? true)
_socat = State(initialValue: existing?.mpv?.socat ?? "socat")
_volumeScale = State(initialValue: existing?.mpv.map { String($0.volumeScale) } ?? "130")
_helperBin = State(initialValue: existing?.commands?.launchFile?.first ?? existing?.ssh?.bin ?? "/usr/local/bin/black-tv")
_rokuHost = State(initialValue: existing?.roku?.host ?? "")
_rokuPort = State(initialValue: existing?.roku.map { String($0.port) } ?? "8060")
_offlinePolicy = State(initialValue: existing?.resolvedOfflinePolicy() ?? .defaults)
_streamPolicy = State(initialValue: existing?.resolvedStreamPolicy() ?? .defaults)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(existing == nil ? "Add Device" : "Edit Device").font(.title2).bold().padding()
Form {
Section {
TextField("Name", text: $name)
TextField("Hostname", text: $hostname, prompt: Text(hostnamePrompt))
.help("Mesh DNS short name — endpoints default to <hostname>.lan then <hostname>.wg")
Picker("Type", selection: $type) {
ForEach(DeviceType.allCases) { Label($0.label, systemImage: $0.icon).tag($0) }
}
.help("Device type / role — presets the service toggles below (maps to governor class for mesh duties)")
.onChange(of: type) { _, newType in services = newType.defaultServices }
Text("The type presets services below — override any toggle. * services are designed but not yet actuated.")
.font(.caption).foregroundStyle(.secondary)
}
Section("Services") {
Toggle("Stream (playback target)", isOn: $services.stream)
.disabled(kind == .registry)
.help("Send playback commands to this device")
Toggle("Offline cache (next episodes of recent shows)", isOn: $services.offlineCache)
.help("Rsync recent episodes to this device for offline viewing")
Toggle("Seed while playing *", isOn: $services.ttlSeed)
.help("Planned — seed torrents while watching on this device")
Toggle("Hold copies / custody *", isOn: $services.custody)
.help("Planned — retain copies for install custody floor")
Toggle("Public-swarm face *", isOn: $services.publicSwarmFace)
.help("Planned — announce on public trackers via VPN exit")
Toggle("F2F relay *", isOn: $services.f2fRelay)
.help("Planned — friend-to-friend relay for private swarms")
Toggle("Mesh anchor / registry *", isOn: $services.meshAnchor)
.help("Planned — mesh registry anchor for install discovery (broadcast duty)")
}
if services.stream {
Section {
StreamPolicySection(policy: $streamPolicy)
} header: {
Text("Streaming")
} footer: {
Text("Buffer size for on-demand fetch from the storage server. Capped at half the episode length during playback.")
}
}
if services.offlineCache {
Section {
OfflinePolicySection(policy: $offlinePolicy, showWarmupButton: false)
} header: {
Text("Offline cache")
} footer: {
Text("Warmup pulls episodes from black; culling caps on-disk storage as a % of drive size.")
}
}
Section {
Picker("Backend", selection: $kind) {
ForEach(HostKind.editable) { Text($0.label).tag($0) }
}
.help("How this app talks to the player on the device")
// A registry-only device can't be a playback target the
// stream toggle follows the backend (and is disabled above).
.onChange(of: kind) { _, newKind in
if newKind == .registry { services.stream = false }
}
} header: { Text("Player backend") }
switch kind {
case .vlc:
Section("VLC HTTP") {
TextField("Address", text: $vlcHost)
.help("VLC HTTP interface host (usually 127.0.0.1 for local VLC)")
TextField("Port", text: $vlcPort)
.help("VLC HTTP interface port (default 8080)")
Text("Password is read from the VLC config, not stored here.")
.font(.caption).foregroundStyle(.secondary)
}
case .mpvIPC:
Section("mpv over SSH") {
TextField("SSH endpoints (override)", text: $endpoints,
prompt: Text(endpointPlaceholder))
.help("Leave empty to use hostname — e.g. lilith@black.lan, lilith@black.wg")
TextField("mpv IPC socket", text: $socket)
.help("Path to the mpv JSON IPC socket on the remote (usually /tmp/mpv.sock)")
Toggle("Socket needs sudo", isOn: $sudo)
.help("Whether socat needs sudo to reach the mpv IPC socket on the remote host")
TextField("socat binary", text: $socat)
.help("Name or path of socat on the remote (for root-owned sockets)")
TextField("Volume scale (max %)", text: $volumeScale)
.help("mpv --volume-max value for this host (normalizes the slider)")
TextField("Helper command (launch/releases/stats)", text: $helperBin)
.help("e.g. /usr/local/bin/black-tv — delegated ops the IPC can't do")
}
case .quicktime:
Section("QuickTime") {
Text("Local playback via AppleScript. No configuration needed.")
.font(.caption).foregroundStyle(.secondary)
}
case .roku:
Section("Roku ECP") {
TextField("Address", text: $rokuHost)
.help("The Roku's LAN IP — discoverable via SSDP (ST: roku:ecp)")
TextField("Port", text: $rokuPort)
.help("ECP port (default 8060)")
Text("Transport control of the stick's own playback (no auth — LAN REST). The library never plays TO a Roku; that needs the planned dev channel.")
.font(.caption).foregroundStyle(.secondary)
}
case .registry:
Section("Registry only") {
Text("No player backend — the device is tracked with its role and services, but this app never plays to it. Right for phones and tablets (they stream through their own app). To put it on the mesh, use Settings → Device Mesh.")
.font(.caption).foregroundStyle(.secondary)
}
case .blacktv:
EmptyView()
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Cancel") { dismiss() }
.help("Discard changes")
Button("Save") { onSave(build()); dismiss() }
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
.help("Save device configuration to devices.json")
}
.padding()
}
.frame(width: 480, height: sheetHeight)
}
private var sheetHeight: CGFloat {
var h: CGFloat = 600
if services.stream { h += 100 }
if services.offlineCache { h += 120 }
return h
}
private var hostnamePrompt: String {
if let existing { return existing.resolvedHostname() }
return kind.isLocal ? DeviceHostname.systemShortName() : slug(name)
}
private var endpointPlaceholder: String {
let host = hostname.trimmingCharacters(in: .whitespaces).isEmpty
? (existing?.resolvedHostname() ?? slug(name))
: hostname.trimmingCharacters(in: .whitespaces).lowercased()
return DeviceHostname.sshEndpoints(host: host).joined(separator: ", ")
}
private func build() -> DeviceConfig {
let did = existing?.id ?? slug(name)
let policy = services.offlineCache ? offlinePolicy : nil
let stream = services.stream ? streamPolicy : nil
let hn = hostname.trimmingCharacters(in: .whitespaces)
let hostField = hn.isEmpty ? nil : hn.lowercased()
switch kind {
case .vlc:
return DeviceConfig(id: did, name: name, kind: .vlc, hostname: hostField,
type: type, services: services,
vlc: VLCConn(host: vlcHost, port: Int(vlcPort) ?? 8080),
offlinePolicy: policy, streamPolicy: stream)
case .quicktime:
return DeviceConfig(id: did, name: name, kind: .quicktime, hostname: hostField,
type: type, services: services, offlinePolicy: policy,
streamPolicy: stream)
case .registry:
var s = services
s.stream = false // no backend never a playback target, whatever the toggles say
return DeviceConfig(id: did, name: name, kind: .registry, hostname: hostField,
type: type, services: s, offlinePolicy: policy,
streamPolicy: stream)
case .roku:
return DeviceConfig(id: did, name: name, kind: .roku, hostname: hostField,
type: type, services: services,
roku: RokuConn(host: rokuHost, port: Int(rokuPort) ?? 8060),
offlinePolicy: policy, streamPolicy: stream)
case .mpvIPC, .blacktv:
let eps = endpoints.split(whereSeparator: { ", \n\t".contains($0) }).map(String.init)
let mpv = MpvConn(endpoints: eps, socket: socket, sudo: sudo,
socat: socat, volumeScale: Int(volumeScale) ?? 130)
let cmds = helperBin.trimmingCharacters(in: .whitespaces).isEmpty
? nil : CommandsConfig.blackTVDefaults(bin: helperBin)
return DeviceConfig(id: did, name: name, kind: .mpvIPC, hostname: hostField,
type: type, services: services,
mpv: mpv, commands: cmds, offlinePolicy: policy,
streamPolicy: stream)
}
}
private func slug(_ s: String) -> String {
let base = s.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }
return String(base).split(separator: "-").joined(separator: "-")
}
}