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>
347 lines
No EOL
16 KiB
Swift
347 lines
No EOL
16 KiB
Swift
import SwiftUI
|
|
import TVAnarchyCore
|
|
import AppKit
|
|
|
|
/// Device registry for this install: every device, connection health, service ops, add/edit/delete.
|
|
/// Local device state and offline policy live on the This Mac tab. Offline items list (playable, dynamic fill/cull) is its own top-level tab. (Install membership is managed via Device Mesh in Settings.)
|
|
struct DevicesView: View {
|
|
@Bindable var controller: PlayerController
|
|
@Bindable var offline: OfflineCacheController
|
|
var onManageLocal: (() -> Void)? = nil
|
|
@State private var editing: DeviceConfig?
|
|
@State private var adding = false
|
|
@State private var confirmReset = false
|
|
@State private var restarting: Set<String> = []
|
|
@State private var restartNote: String?
|
|
@State private var expanded: Set<String> = []
|
|
|
|
private var devices: [DeviceConfig] { controller.editableDevices }
|
|
private var localDevice: DeviceConfig? { devices.first { $0.kind.isLocal } }
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Devices").font(.title2).bold()
|
|
Text("Device registry — add hosts, check reachability, restart remote services.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button { adding = true } label: { Label("Add Device", systemImage: "plus") }
|
|
.help("Add or register a new playback device, storage, or other node in the install")
|
|
}
|
|
|
|
if let local = localDevice {
|
|
localDeviceRow(local)
|
|
Divider()
|
|
}
|
|
|
|
Text("Install devices").font(.headline)
|
|
|
|
List(devices.filter { !$0.kind.isLocal }) { d in
|
|
deviceRow(d)
|
|
}
|
|
|
|
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 local player set?",
|
|
isPresented: $confirmReset, titleVisibility: .visible) {
|
|
Button("Reset", role: .destructive) { controller.resetDevicesToDefault() }
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
}
|
|
|
|
private func localDeviceRow(_ local: DeviceConfig) -> some View {
|
|
let snap = controller.snapshot(local.id)
|
|
return HStack(spacing: 12) {
|
|
Image(systemName: local.type.icon).foregroundStyle(.tint).frame(width: 24)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("This Mac — \(local.name)").font(.headline)
|
|
Text("\(local.kind.label) · \(servicesSummary(local.services))")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state))
|
|
if let onManageLocal {
|
|
Button("Manage") { onManageLocal() }
|
|
.help("Open This Mac for local playback/policy; use Offline tab for the playable cached-items list")
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func deviceRow(_ d: DeviceConfig) -> some View {
|
|
let snap = controller.snapshot(d.id)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
Circle().fill(d.kind == .registry ? Color.gray.opacity(0.4) : 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 let issues = controller.hostStatsByID[d.id]?.deps?.issues, !issues.isEmpty {
|
|
depsPill(issues)
|
|
}
|
|
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…")
|
|
}
|
|
if d.kind == .registry {
|
|
Text("Registry").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
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)
|
|
.help("Switch the toolbar host selector to this device for playback")
|
|
if controller.canRestartService(d.id) {
|
|
Button("Restart service") { restartService(d) }
|
|
.disabled(restarting.contains(d.id))
|
|
.help("Restart the remote helper / black-tv on this device")
|
|
}
|
|
if controller.canUpdateService(d.id) {
|
|
Button("Update & restart service") { updateService(d) }
|
|
.disabled(restarting.contains(d.id))
|
|
.help("Deploy latest helper and restart service on this device")
|
|
}
|
|
Button("Edit…") { editing = d }
|
|
.help("Edit name, type, services, backend and offline policy")
|
|
Button("Delete", role: .destructive) { controller.deleteDevice(d.id) }
|
|
.disabled(devices.count <= 1)
|
|
.help("Remove this device from the registry")
|
|
} 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)
|
|
if d.services.offlineCache {
|
|
offlinePolicySummary(d)
|
|
.padding(.leading, 42).padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
@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) · governor \(d.type.fleetClass) · \(servicesSummary(d.services))")
|
|
summaryRow("Connection", d.kind == .registry
|
|
? "registry entry — this app never connects to it" : 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 facts = controller.hostStatsByID[d.id]?.deps?.facts, !facts.isEmpty {
|
|
summaryRow("Dependencies", facts.map {
|
|
($0.severity == .ok ? "" : "⚠ ") + $0.text
|
|
}.joined(separator: " · "))
|
|
}
|
|
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) }
|
|
}
|
|
if controller.canUpdateService(d.id) {
|
|
Button("Update & restart") { updateService(d) }
|
|
}
|
|
if restarting.contains(d.id) { ProgressView().controlSize(.small) }
|
|
}
|
|
.controlSize(.small)
|
|
.disabled(restarting.contains(d.id))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func offlinePolicySummary(_ d: DeviceConfig) -> some View {
|
|
let p = d.resolvedOfflinePolicy()
|
|
return Text("Offline: \(p.episodesAhead) ahead · \(p.episodesBehind) behind · \(p.shows) shows · \(p.budgetPercent)% drive · warmup \(p.warmupEnabled ? "on" : "off")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
|
|
private func summaryRow(_ label: String, _ value: String) -> some View {
|
|
GridRow {
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
.gridColumnAlignment(.trailing)
|
|
Text(value).font(.caption).textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)"
|
|
}
|
|
}
|
|
|
|
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.resolvedSSHEndpoints().joined(separator: ", ")
|
|
case .blacktv: (d.ssh?.endpoints ?? []).joined(separator: ", ")
|
|
case .quicktime: "local"
|
|
case .roku: d.roku.map { "ecp://\($0.host):\($0.port)" } ?? "—"
|
|
case .registry: "no player backend"
|
|
}
|
|
}
|
|
|
|
private func depsPill(_ issues: [DepFact]) -> some View {
|
|
let isError = issues.contains { $0.severity == .error }
|
|
let c: Color = isError ? .red : .orange
|
|
return Label(issues.count == 1 ? issues[0].text : "\(issues.count) issues",
|
|
systemImage: isError ? "exclamationmark.octagon" : "exclamationmark.triangle")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6).padding(.vertical, 1)
|
|
.background(c.opacity(0.2), in: Capsule())
|
|
.foregroundStyle(c)
|
|
.help(issues.map(\.text).joined(separator: " · "))
|
|
}
|
|
|
|
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).")
|
|
}
|
|
|
|
@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" }
|
|
}
|
|
} |