tv-anarchy/Sources/TVAnarchy/DevicesView.swift
Natalie a86e68c525 feat(apps): add fleet engine mesh core integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 21:23:36 -07:00

164 lines
7.7 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 restart in flight
@State private var restartNote: String?
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)
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 restarting.contains(d.id) {
ProgressView().controlSize(.small)
.help("Restarting the player service…")
}
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))
}
Button("Edit…") { editing = d }
Button("Delete", role: .destructive) { controller.deleteDevice(d.id) }
.disabled(devices.count <= 1)
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize()
}
.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"
}
}
/// 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"
}
}
/// 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" }
}
}