tv-anarchy/Sources/TVAnarchy/DevicesView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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" }
}
}