tv-anarchy/Sources/TVAnarchy/DeviceEditView.swift
Natalie ca1871f5dd feat(@applications/tv-anarchy): add roku device support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 21:37:34 -07:00

152 lines
7.8 KiB
Swift
Raw 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 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
init(existing: DeviceConfig?, onSave: @escaping (DeviceConfig) -> Void) {
self.existing = existing
self.onSave = onSave
let t = existing?.type ?? .laptop
_name = State(initialValue: existing?.name ?? "")
_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")
_endpoints = State(initialValue: (existing?.mpv?.endpoints ?? existing?.ssh?.endpoints ?? []).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")
}
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)
Picker("Type", selection: $type) {
ForEach(DeviceType.allCases) { Label($0.label, systemImage: $0.icon).tag($0) }
}
.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)
Toggle("Offline cache (next episodes of recent shows)", isOn: $services.offlineCache)
Toggle("Seed while playing *", isOn: $services.ttlSeed)
Toggle("Hold copies / custody *", isOn: $services.custody)
Toggle("Public-swarm face *", isOn: $services.publicSwarmFace)
Toggle("F2F relay *", isOn: $services.f2fRelay)
Toggle("Mesh anchor / registry *", isOn: $services.meshAnchor)
}
Section {
Picker("Backend", selection: $kind) {
ForEach(HostKind.editable) { Text($0.label).tag($0) }
}
} header: { Text("Player backend") }
switch kind {
case .vlc:
Section("VLC HTTP") {
TextField("Address", text: $vlcHost)
TextField("Port", text: $vlcPort)
Text("Password is read from the VLC config, not stored here.")
.font(.caption).foregroundStyle(.secondary)
}
case .mpvIPC:
Section("mpv over SSH") {
TextField("SSH endpoints (comma-separated, in try order)", text: $endpoints)
.help("e.g. lilith@10.0.0.11, lilith@10.9.0.4")
TextField("mpv IPC socket", text: $socket)
Toggle("Socket needs sudo", isOn: $sudo)
TextField("socat binary", text: $socat)
TextField("Volume scale (max %)", text: $volumeScale)
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)
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 .blacktv:
EmptyView()
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Save") { onSave(build()); dismiss() }
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
}
.frame(width: 460, height: 600)
}
private func build() -> DeviceConfig {
let did = existing?.id ?? slug(name)
switch kind {
case .vlc:
return DeviceConfig(id: did, name: name, kind: .vlc, type: type, services: services,
vlc: VLCConn(host: vlcHost, port: Int(vlcPort) ?? 8080))
case .quicktime:
return DeviceConfig(id: did, name: name, kind: .quicktime, type: type, services: services)
case .roku:
return DeviceConfig(id: did, name: name, kind: .roku, type: type, services: services,
roku: RokuConn(host: rokuHost, port: Int(rokuPort) ?? 8060))
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, type: type, services: services,
mpv: mpv, commands: cmds)
}
}
private func slug(_ s: String) -> String {
let base = s.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }
return String(base).split(separator: "-").joined(separator: "-")
}
}