354 lines
14 KiB
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) }
|
|
}
|