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

288 lines
14 KiB
Swift

import SwiftUI
import TVAnarchyCore
import AppKit
/// Operational device list: live connection state per device, its type + active
/// services, plus add/edit/delete and "make active". Editing opens the device
/// editor; configuration persists to devices.json via the controller.
struct DevicesView: View {
@Bindable var controller: PlayerController
@Bindable var offline: OfflineCacheController
@State private var editing: DeviceConfig? // edit sheet (existing device)
@State private var adding = false // add sheet
@State private var confirmReset = false
@State private var restarting: Set<String> = [] // device ids with a service op in flight
@State private var restartNote: String?
@State private var expanded: Set<String> = [] // device ids with the summary open
private var devices: [DeviceConfig] { controller.editableDevices }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Devices").font(.title2).bold()
Spacer()
Button { adding = true } label: { Label("Add Device", systemImage: "plus") }
}
List(devices) { d in
let snap = controller.snapshot(d.id)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 12) {
Circle().fill(color(snap.state)).frame(width: 10)
Image(systemName: d.type.icon).foregroundStyle(.secondary).frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(d.name).font(.headline)
Text(d.type.label).font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.quaternary, in: Capsule())
if d.id == controller.activeID {
Text("active").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.tint.opacity(0.2), in: Capsule())
}
}
Text("\(d.kind.label) · \(detail(d))")
.font(.caption).foregroundStyle(.secondary)
Text(servicesSummary(d.services))
.font(.caption2).foregroundStyle(.tertiary)
}
Spacer()
if d.services.offlineCache {
Button { Task { await offline.cacheNow() } } label: {
Image(systemName: "arrow.down.to.line")
}
.buttonStyle(.borderless).disabled(offline.caching)
.help("Cache the next episodes of your recent shows to this device")
}
if let s = controller.hostStatsByID[d.id] { loadPill(s) }
if case .outdated(let deployed)? = controller.helperFreshness(d.id) {
outdatedPill(deployed: deployed)
}
if restarting.contains(d.id) {
ProgressView().controlSize(.small)
.help("Service operation in flight…")
}
Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state))
Menu {
Button("Make active") { controller.setActive(d.id) }
.disabled(d.id == controller.activeID || !d.services.stream)
if controller.canRestartService(d.id) {
Button("Restart service") { restartService(d) }
.disabled(restarting.contains(d.id))
}
if controller.canUpdateService(d.id) {
Button("Update & restart service") { updateService(d) }
.disabled(restarting.contains(d.id))
}
Button("Edit…") { editing = d }
Button("Delete", role: .destructive) { controller.deleteDevice(d.id) }
.disabled(devices.count <= 1)
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize()
Button {
if expanded.contains(d.id) { expanded.remove(d.id) }
else { expanded.insert(d.id) }
} label: {
Image(systemName: expanded.contains(d.id) ? "chevron.up" : "chevron.down")
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.help("Show device details")
}
if expanded.contains(d.id) {
summary(d, snap)
.padding(.leading, 42).padding(.top, 8).padding(.bottom, 2)
}
}
.padding(.vertical, 4)
}
if let s = offline.status {
Text(s).font(.caption).foregroundStyle(.secondary)
}
if let restartNote {
Text(restartNote).font(.caption).foregroundStyle(.secondary)
}
HStack {
Button("Reload config") { controller.reload() }
Button("Reset to defaults") { confirmReset = true }
Button("Reveal devices.json") {
NSWorkspace.shared.activateFileViewerSelecting([DevicesConfig.configURL()])
}
Spacer()
Text(DevicesConfig.configURL().path)
.font(.caption.monospaced()).foregroundStyle(.tertiary)
}
}
.padding(24)
.sheet(isPresented: $adding) {
DeviceEditView(existing: nil) { controller.upsertDevice($0) }
}
.sheet(item: $editing) { d in
DeviceEditView(existing: d) { controller.upsertDevice($0) }
}
.confirmationDialog("Reset devices to the default plum VLC + black mpv set?",
isPresented: $confirmReset, titleVisibility: .visible) {
Button("Reset", role: .destructive) { controller.resetDevicesToDefault() }
Button("Cancel", role: .cancel) {}
}
}
/// Restart the device's host-side player service (e.g. black's mpv unit) and
/// surface the outcome inline; the row spins while the restart is in flight.
private func restartService(_ d: DeviceConfig) {
restarting.insert(d.id)
restartNote = "Restarting \(d.name)"
Task {
let ok = await controller.restartService(d.id)
restarting.remove(d.id)
restartNote = ok ? "\(d.name): service restarted" : "\(d.name): service restart failed"
}
}
/// Push the repo's helper to the device, then restart its service the
/// in-app recovery for "black is wedged / running an old script".
private func updateService(_ d: DeviceConfig) {
restarting.insert(d.id)
restartNote = "Updating \(d.name)"
Task {
let ok = await controller.updateService(d.id)
restarting.remove(d.id)
restartNote = ok ? "\(d.name): service updated & restarted"
: "\(d.name): service update failed"
}
}
// MARK: expanded summary
/// The expanded per-device summary: backend + endpoints, role + services,
/// live connection/playback, host load, and helper-deployment freshness
/// (deployed vs repo hash) plus the service actions, so diagnose-and-fix
/// happens in one place.
@ViewBuilder
private func summary(_ d: DeviceConfig, _ snap: PlayerController.Snapshot) -> some View {
VStack(alignment: .leading, spacing: 8) {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 3) {
summaryRow("Backend", "\(d.kind.label) · \(detail(d))")
summaryRow("Role", "\(d.type.label) (fleet \(d.type.fleetClass)) · \(servicesSummary(d.services))")
summaryRow("Connection", connectionLine(snap))
if let s = controller.hostStatsByID[d.id] {
summaryRow("Load", String(format: "%.2f / %.2f / %.2f over %d cores",
s.load1, s.load5, s.load15, s.cores)
+ (s.mpv_cpu.map { String(format: " · mpv %.0f%% CPU", $0) } ?? ""))
}
if let line = deploymentLine(d) { summaryRow("Service", line) }
}
if controller.canRestartService(d.id) || controller.canUpdateService(d.id) {
HStack(spacing: 8) {
if controller.canRestartService(d.id) {
Button("Restart service") { restartService(d) }
.help("Hard-restart the host-side player, resuming what was playing")
}
if controller.canUpdateService(d.id) {
Button("Update & restart") { updateService(d) }
.help("Push the repo's helper script to the device, verify it landed, then restart the player service through it")
}
if restarting.contains(d.id) { ProgressView().controlSize(.small) }
}
.controlSize(.small)
.disabled(restarting.contains(d.id))
}
}
}
private func summaryRow(_ label: String, _ value: String) -> some View {
GridRow {
Text(label).font(.caption).foregroundStyle(.secondary)
.gridColumnAlignment(.trailing)
Text(value).font(.caption).textSelection(.enabled)
}
}
/// "Connected · playing: <title>" reachability plus what the device is doing.
private func connectionLine(_ snap: PlayerController.Snapshot) -> String {
var line = stateLabel(snap.state)
if snap.status.playing, let t = snap.status.title {
line += " · \(snap.status.paused == true ? "paused" : "playing"): \(t)"
} else if snap.state == .connected {
line += " · idle"
}
return line
}
/// Helper-deployment line: deployed hash vs the repo's, judged. nil when the
/// device has no helper and nothing was ever reported (local players, Roku).
private func deploymentLine(_ d: DeviceConfig) -> String? {
let expected = controller.helperExpectedSHA(d.id)
let deployed = controller.hostStatsByID[d.id]?.helper_sha
guard expected != nil || deployed != nil else { return nil }
let dep = deployed.map { String($0.prefix(7)) } ?? "unstamped"
guard let expected else { return "deployed \(dep) · no repo checkout to compare against" }
let exp = String(expected.prefix(7))
switch controller.helperFreshness(d.id) {
case .current: return "up to date (\(exp))"
case .outdated: return "NOT up to date — deployed \(dep), repo has \(exp)"
case nil: return "no report yet — repo has \(exp)"
}
}
/// Compact "stream · offline · seed · custody" summary of the on services.
private func servicesSummary(_ s: DeviceServices) -> String {
var on: [String] = []
if s.stream { on.append("stream") }
if s.offlineCache { on.append("offline") }
if s.ttlSeed { on.append("seed*") }
if s.custody { on.append("custody*") }
if s.publicSwarmFace { on.append("swarm*") }
if s.f2fRelay { on.append("relay*") }
if s.meshAnchor { on.append("anchor*") }
return on.isEmpty ? "no services" : on.joined(separator: " · ")
}
private func detail(_ d: DeviceConfig) -> String {
switch d.kind {
case .vlc: d.vlc.map { "\($0.host):\($0.port)" } ?? ""
case .mpvIPC: (d.mpv?.endpoints ?? []).joined(separator: ", ")
case .blacktv: (d.ssh?.endpoints ?? []).joined(separator: ", ")
case .quicktime: "local"
case .roku: d.roku.map { "ecp://\($0.host):\($0.port)" } ?? ""
}
}
/// Stale-deploy badge: the helper script running on the device is not the
/// one vendored in this repo (or predates self-reporting entirely), so the
/// app may be speaking verbs the device doesn't know yet. Redeploy per
/// mcp/README to clear it.
private func outdatedPill(deployed: String?) -> some View {
Label("not up to date", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(.orange.opacity(0.2), in: Capsule())
.foregroundStyle(.orange)
.help("The helper deployed on this device (\(deployed.map { String($0.prefix(7)) } ?? "unstamped — pre-2026-06 deploy")) "
+ "differs from the repo's copy — redeploy it (mcp/README → deploy step).")
}
/// System-load badge: load1 normalised by core count. <0.6/core is comfortable,
/// up to 1.0/core is busy-but-keeping-up, above means the run queue is oversubscribed.
@ViewBuilder
private func loadPill(_ s: HostStats) -> some View {
let ratio = s.cores > 0 ? s.load1 / Double(s.cores) : s.load1
let (label, c): (String, Color) = ratio < 0.6 ? ("load low", .green)
: ratio < 1.0 ? ("load med", .yellow)
: ("load high", .orange)
Text(label)
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(c.opacity(0.2), in: Capsule())
.foregroundStyle(c)
.help(String(format: "load %.2f over %d cores", s.load1, s.cores))
}
private func color(_ s: ConnectionState) -> Color {
switch s { case .connected: .green; case .checking: .gray; case .unreachable: .orange }
}
private func stateLabel(_ s: ConnectionState) -> String {
switch s { case .connected: "Connected"; case .checking: "Checking…"; case .unreachable: "Unreachable" }
}
}