tv-anarchy/Sources/TVAnarchy/DevicesView.swift

354 lines
14 KiB
Swift

import SwiftUI
import TVAnarchyCore
import AppKit
/// Devices tab: the fleet registry. Left, the devices (black/apricot/plum/phone);
/// right, the selected device's class/reachability, the combo of services it runs,
/// and any duties assigned to it. Playback services keep the live, feature-detected
/// tools; fleet services (transmission/compute/sink) surface their role. The
/// duty-assignment engine is not here duties are read, not computed.
struct DevicesView: View {
@Bindable var fleet: FleetController
@Bindable var player: PlayerController
@State private var selectedID: String?
private var selected: Device? { fleet.device(selectedID ?? "") }
var body: some View {
HStack(spacing: 0) {
deviceList.frame(width: 250)
Divider()
detailPane.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear { if selected == nil { selectedID = fleet.devices.first?.id } }
}
// MARK: Master list
private var deviceList: some View {
VStack(spacing: 0) {
HStack {
Text("Devices").font(.title2).bold()
Spacer()
Menu {
Button("Reload") { fleet.reload() }
Button("Reveal fleet.json") {
NSWorkspace.shared.activateFileViewerSelecting([FleetConfig.configURL()])
}
Divider()
Button("Reset to home fleet", role: .destructive) {
fleet.resetToSeed(); selectedID = fleet.devices.first?.id
}
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize()
}
.padding(.horizontal, 16).padding(.vertical, 12)
List(fleet.devices, selection: $selectedID) { d in
deviceRow(d).tag(d.id)
}
}
}
private func deviceRow(_ d: Device) -> some View {
HStack(spacing: 10) {
Image(systemName: d.deviceClass.icon)
.foregroundStyle(.secondary).frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Text(d.name).font(.headline)
if hasActivePlayback(d) {
Text("active").font(.caption2)
.padding(.horizontal, 5).padding(.vertical, 1)
.background(.tint.opacity(0.2), in: Capsule())
}
}
HStack(spacing: 5) {
Text(d.deviceClass.label).font(.caption).foregroundStyle(.secondary)
ForEach(d.services) { s in
Image(systemName: s.kind.icon).font(.caption2).foregroundStyle(.tertiary)
}
if !d.duties.isEmpty {
Text("\(d.duties.count)").font(.caption2).foregroundStyle(.tertiary)
}
}
}
Spacer()
}
.padding(.vertical, 2)
}
private func hasActivePlayback(_ d: Device) -> Bool {
d.playbackServices.contains { ($0.playbackHostID ?? $0.id) == player.activeID }
}
// MARK: Detail
@ViewBuilder private var detailPane: some View {
if let d = selected {
DeviceDetailView(fleet: fleet, player: player, deviceID: d.id).id(d.id)
} else {
VStack(spacing: 8) {
Image(systemName: "square.stack.3d.up").font(.largeTitle).foregroundStyle(.tertiary)
Text("Select a device").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
/// Per-device detail: identity/class/reachability (editable), the services it runs
/// (each a card playback cards carry live tools), and assigned duties (read-only).
struct DeviceDetailView: View {
@Bindable var fleet: FleetController
@Bindable var player: PlayerController
let deviceID: String
private var device: Device? { fleet.device(deviceID) }
var body: some View {
ScrollView {
if let d = device {
VStack(alignment: .leading, spacing: 26) {
header(d)
deviceSection(d)
servicesSection(d)
dutiesSection(d)
}
.padding(28).frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private func header(_ d: Device) -> some View {
HStack(spacing: 12) {
Image(systemName: d.deviceClass.icon).font(.title).foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text(d.name).font(.title).bold()
Text("\(d.deviceClass.label) · \(d.reachable.label)")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
Button("Delete", role: .destructive) { fleet.deleteDevice(d.id) }
.disabled(fleet.devices.count <= 1)
}
}
// MARK: Device attributes (editable, write-through to fleet.json)
private func deviceSection(_ d: Device) -> some View {
sectionBox("Device") {
Picker("Class", selection: bind(d, \.deviceClass)) {
ForEach(DeviceClass.allCases) { Text($0.label).tag($0) }
}
Picker("Reachable", selection: bind(d, \.reachable)) {
ForEach(Reachability.allCases) { Text($0.label).tag($0) }
}
Toggle("Always on", isOn: bind(d, \.alwaysOn))
Toggle("On home IP", isOn: bind(d, \.onHomeIp))
.help("Public-swarm traffic over a home IP exposes the home connection.")
}
}
// MARK: Services (the combo this device runs)
private func servicesSection(_ d: Device) -> some View {
sectionBox("Services") {
ForEach(d.services) { s in
ServiceCard(service: s, player: player,
onRemove: { fleet.removeService(s.id, from: d.id) },
canRemove: d.services.count > 1)
if s.id != d.services.last?.id { Divider() }
}
}
}
// MARK: Duties (read-only assigned by the governor/fleet engine)
private func dutiesSection(_ d: Device) -> some View {
sectionBox("Duties") {
if d.duties.isEmpty {
Text(d.deviceClass == .consumer
? "Consumer — never receives a duty."
: "No duties assigned.")
.font(.callout).foregroundStyle(.secondary)
} else {
HStack(spacing: 8) {
ForEach(d.duties) { duty in
Text(duty.label).font(.caption)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(.tint.opacity(0.15), in: Capsule())
}
}
}
Text("Assigned deterministically by the governor on registry change — read-only here.")
.font(.caption).foregroundStyle(.tertiary)
}
}
// MARK: write-through binding helper
private func bind<V>(_ d: Device, _ key: WritableKeyPath<Device, V>) -> Binding<V> {
Binding(
get: { fleet.device(d.id)?[keyPath: key] ?? d[keyPath: key] },
set: { newValue in
guard var cur = fleet.device(d.id) else { return }
cur[keyPath: key] = newValue
fleet.update(cur)
})
}
private func sectionBox<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title).font(.headline)
content()
}
}
}
/// One service row. Playback services resolve their live `PlayerTarget` and expose
/// the feature-detected tools (Stop / host load / releases / fullscreen / clear);
/// fleet services show their role. A service with no live target shows that plainly.
struct ServiceCard: View {
let service: Service
@Bindable var player: PlayerController
let onRemove: () -> Void
let canRemove: Bool
@State private var stats: HostStats?
@State private var releases: [Release] = []
private var hostID: String { service.playbackHostID ?? service.id }
private var target: (any PlayerTarget)? {
service.kind.isPlayback ? player.target(hostID) : nil
}
private var isActive: Bool { service.kind.isPlayback && hostID == player.activeID }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: service.kind.icon).foregroundStyle(.secondary).frame(width: 18)
VStack(alignment: .leading, spacing: 1) {
Text(service.kind.label).font(.headline)
if let d = service.detail {
Text(d).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}
if isActive {
Text("active").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.tint.opacity(0.2), in: Capsule()).foregroundStyle(.tint)
}
Spacer()
if service.kind.isPlayback {
Circle().fill(dotColor).frame(width: 9, height: 9)
}
if canRemove {
Button { onRemove() } label: { Image(systemName: "minus.circle") }
.buttonStyle(.borderless).foregroundStyle(.secondary)
.help("Remove service")
}
}
if service.kind.isPlayback {
playbackTools
} else {
Text(roleNote).font(.callout).foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.task(id: service.id) { await loadTools() }
}
// MARK: playback tools (feature-detected against the live target)
@ViewBuilder private var playbackTools: some View {
let t = target
if t == nil {
Text("No live connection — host unreachable or not configured.")
.font(.callout).foregroundStyle(.secondary)
} else {
HStack(spacing: 10) {
Button("Make active") { player.setActive(hostID) }.disabled(isActive)
Button(role: .destructive) {
player.runTool(on: hostID) { await $0.stop() }
} label: { Label("Stop", systemImage: "stop.fill") }
if t is FullscreenControllable {
Button {
player.runTool(on: hostID) { await ($0 as? FullscreenControllable)?.toggleFullscreen() }
} label: { Label("Fullscreen", systemImage: "arrow.up.left.and.arrow.down.right") }
}
if t is PlaylistClearable {
Button {
player.runTool(on: hostID) { await ($0 as? PlaylistClearable)?.clearPlaylist() }
} label: { Label("Clear", systemImage: "trash") }
}
}
.buttonStyle(.bordered).controlSize(.small)
if t is HostStatsProvider { statsRow }
if t is QualitySwitchable { releasesRow }
}
}
private var statsRow: some View {
HStack(spacing: 8) {
Image(systemName: "gauge.with.dots.needle.50percent").foregroundStyle(.secondary)
if let s = stats {
Text("load \(fmt(s.load1)) · \(s.cores) cores" +
(s.mpv_cpu.map { " · decode \(fmt($0))%" } ?? ""))
.font(.callout.monospaced()).foregroundStyle(.secondary)
} else {
Text("no load data").font(.callout).foregroundStyle(.secondary)
}
Button { Task { stats = await player.deviceStats(hostID) } } label: {
Image(systemName: "arrow.clockwise")
}.buttonStyle(.borderless).help("Refresh")
}
}
@ViewBuilder private var releasesRow: some View {
if releases.isEmpty {
HStack(spacing: 6) {
Image(systemName: "rectangle.stack").foregroundStyle(.secondary)
Text("no alternate releases").font(.callout).foregroundStyle(.secondary)
Button { Task { releases = await player.deviceReleases(hostID) } } label: {
Image(systemName: "arrow.clockwise")
}.buttonStyle(.borderless).help("Refresh")
}
} else {
VStack(alignment: .leading, spacing: 4) {
ForEach(releases) { r in
HStack {
Text(r.label) + Text(r.current ? " (current)" : "").foregroundColor(.secondary)
Spacer()
Button("Switch") {
player.runTool(on: hostID) { await ($0 as? QualitySwitchable)?.switchRelease(r.id) }
}.disabled(r.current).controlSize(.small)
}
}
}
}
}
private var roleNote: String {
switch service.kind {
case .transmission: "Torrent client — downloads and seeds. Manage transfers in the Downloads tab."
case .compute: "Offload target for GPU/CPU-heavy work (MLX title refining, ffmpeg, recommendations)."
case .sink: "Pure consumer — receives playback, never assigned a fleet duty."
default: ""
}
}
private func loadTools() async {
guard service.kind.isPlayback else { return }
stats = await player.deviceStats(hostID)
releases = await player.deviceReleases(hostID)
}
private var dotColor: Color {
switch player.snapshot(hostID).state {
case .connected: .green; case .checking: .gray; case .unreachable: .orange
}
}
private func fmt(_ d: Double) -> String { String(format: "%.2f", d) }
}