164 lines
7.7 KiB
Swift
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" }
|
|
}
|
|
}
|