diff --git a/Sources/TVAnarchy/DevicesView.swift b/Sources/TVAnarchy/DevicesView.swift new file mode 100644 index 0000000..7aa5e76 --- /dev/null +++ b/Sources/TVAnarchy/DevicesView.swift @@ -0,0 +1,354 @@ +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(_ d: Device, _ key: WritableKeyPath) -> Binding { + 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(_ 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) } +} diff --git a/Sources/TVAnarchy/HostsView.swift b/Sources/TVAnarchy/HostsView.swift deleted file mode 100644 index 9f13528..0000000 --- a/Sources/TVAnarchy/HostsView.swift +++ /dev/null @@ -1,91 +0,0 @@ -import SwiftUI -import TVAnarchyCore -import AppKit - -/// Full host configuration: live connection state per host, plus add/edit/delete -/// and "make active". Persists to hosts.json via the controller and reloads. -struct HostsView: View { - @Bindable var controller: PlayerController - @State private var editing: HostConfig? // edit sheet (existing host) - @State private var adding = false // add sheet - @State private var confirmReset = false - - private var hosts: [HostConfig] { controller.editableHosts } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Hosts").font(.title2).bold() - Spacer() - Button { adding = true } label: { Label("Add Host", systemImage: "plus") } - } - - List(hosts) { h in - let snap = controller.snapshot(h.id) - HStack(spacing: 12) { - Circle().fill(color(snap.state)).frame(width: 10) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(h.name).font(.headline) - if h.id == controller.activeID { - Text("active").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1) - .background(.tint.opacity(0.2), in: Capsule()) - } - } - Text("\(h.kind.label) · \(detail(h))") - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state)) - Menu { - Button("Make active") { controller.setActive(h.id) }.disabled(h.id == controller.activeID) - Button("Edit…") { editing = h } - Button("Delete", role: .destructive) { controller.deleteHost(h.id) } - .disabled(hosts.count <= 1) - } label: { Image(systemName: "ellipsis.circle") } - .menuStyle(.borderlessButton).fixedSize() - } - .padding(.vertical, 4) - } - - HStack { - Button("Reload config") { controller.reload() } - Button("Reset to defaults") { confirmReset = true } - Button("Reveal hosts.json") { - NSWorkspace.shared.activateFileViewerSelecting([HostsConfig.configURL()]) - } - Spacer() - Text(HostsConfig.configURL().path) - .font(.caption.monospaced()).foregroundStyle(.tertiary) - } - } - .padding(24) - .sheet(isPresented: $adding) { - HostEditView(existing: nil) { controller.upsertHost($0) } - } - .sheet(item: $editing) { h in - HostEditView(existing: h) { controller.upsertHost($0) } - } - .confirmationDialog("Reset hosts to the default plum VLC + black mpv set?", - isPresented: $confirmReset, titleVisibility: .visible) { - Button("Reset", role: .destructive) { controller.resetHostsToDefault() } - Button("Cancel", role: .cancel) {} - } - } - - private func detail(_ h: HostConfig) -> String { - switch h.kind { - case .vlc: h.vlc.map { "\($0.host):\($0.port)" } ?? "—" - case .mpvIPC: (h.mpv?.endpoints ?? []).joined(separator: ", ") - case .blacktv: (h.ssh?.endpoints ?? []).joined(separator: ", ") - case .quicktime: "local" - } - } - - 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" } - } -} diff --git a/Sources/TVAnarchy/RootView.swift b/Sources/TVAnarchy/RootView.swift index 026fb3e..bbe3fcd 100644 --- a/Sources/TVAnarchy/RootView.swift +++ b/Sources/TVAnarchy/RootView.swift @@ -9,7 +9,7 @@ struct RootView: View { case search = "Search" case downloads = "Downloads" case metadata = "Metadata" - case hosts = "Hosts" + case devices = "Devices" case logs = "Logs" case setup = "Setup" var id: String { rawValue } @@ -21,7 +21,7 @@ struct RootView: View { case .search: return "magnifyingglass" case .downloads: return "arrow.down.circle.fill" case .metadata: return "wand.and.stars" - case .hosts: return "server.rack" + case .devices: return "tv" case .logs: return "list.bullet.rectangle" case .setup: return "gearshape.fill" } @@ -35,6 +35,7 @@ struct RootView: View { @State private var search: SearchController @State private var playlist: PlaylistController @State private var log = LogController() + @State private var fleet = FleetController() @State private var selection: Section = .home @State private var showQueue = false @@ -91,8 +92,8 @@ struct RootView: View { DownloadsView(downloads: downloads, player: controller) case .metadata: MetadataView(metadata: metadata) - case .hosts: - HostsView(controller: controller) + case .devices: + DevicesView(fleet: fleet, player: controller) case .logs: LogView(log: log) case .setup: diff --git a/Sources/TVAnarchyCore/PlayerController.swift b/Sources/TVAnarchyCore/PlayerController.swift index 72e2b95..6fd05db 100644 --- a/Sources/TVAnarchyCore/PlayerController.swift +++ b/Sources/TVAnarchyCore/PlayerController.swift @@ -242,6 +242,41 @@ public final class PlayerController { /// Surface a transient note to the user (e.g. why a click did nothing). public func note(_ message: String?) { actionMessage = message } + // MARK: Device tools (act on a specific configured target, not the active one) + + /// Look up a configured target by id — for the Devices detail pane, whose + /// selection is independent of the playback-active target. + public func target(_ id: String) -> (any PlayerTarget)? { targets.first { $0.id == id } } + + /// Poll one target and apply its snapshot — refresh a device that isn't active + /// (the active one goes through `refreshActive`/the tick loop). + public func refresh(_ id: String) async { + guard let t = target(id), !polling else { return } + polling = true + defer { polling = false } + apply(await t.poll(), to: id) + } + + /// Run a tool against a specific configured device (which may not be the active + /// playback target), then refresh just that device's snapshot. + public func runTool(on id: String, _ op: @escaping (any PlayerTarget) async -> Void) { + guard let t = target(id) else { return } + Task { await op(t); await refresh(id) } + } + + /// Host load for a specific device (nil unless its backend reports stats). + public func deviceStats(_ id: String) async -> HostStats? { + guard let p = target(id) as? HostStatsProvider else { return nil } + return await p.stats() + } + + /// Available releases of what a specific device is playing (empty unless its + /// backend can switch quality). + public func deviceReleases(_ id: String) async -> [Release] { + guard let q = target(id) as? QualitySwitchable else { return [] } + return await q.releases() + } + /// Begin playback of a library request on the active target, then refresh it. /// `series`/`category` let the track preference (sub/dub) resolve and apply /// once the file is loaded; `resumeSeconds` seeks VLC/QuickTime to the saved diff --git a/Sources/TVAnarchyCore/PlayerTarget.swift b/Sources/TVAnarchyCore/PlayerTarget.swift index 36962f8..1a419c6 100644 --- a/Sources/TVAnarchyCore/PlayerTarget.swift +++ b/Sources/TVAnarchyCore/PlayerTarget.swift @@ -47,7 +47,7 @@ public protocol PlayerTarget: AnyObject { var id: String { get } var name: String { get } var kind: HostKind { get } - var detail: String { get } // human-readable endpoint, for the Hosts view + var detail: String { get } // human-readable endpoint, for the Devices view var volumeScale: Int { get } // max value for the volume slider (percent) func poll() async -> PollResult @@ -60,3 +60,16 @@ public protocol PlayerTarget: AnyObject { func previous() async func stop() async } + +/// A target whose backend can toggle fullscreen on its own display (VLC's HTTP +/// `fullscreen` command). mpv-on-black already runs full-screen on the HDMI out, +/// so only the windowed VLC player conforms — the Devices tab feature-detects it. +public protocol FullscreenControllable: AnyObject { + func toggleFullscreen() async +} + +/// A target whose backend keeps a playlist we can wipe in one shot (VLC's +/// `pl_empty`). Surfaced as a per-service tool in the Devices detail pane. +public protocol PlaylistClearable: AnyObject { + func clearPlaylist() async +} diff --git a/Sources/TVAnarchyCore/VLCTarget.swift b/Sources/TVAnarchyCore/VLCTarget.swift index 17c1259..f6edb2a 100644 --- a/Sources/TVAnarchyCore/VLCTarget.swift +++ b/Sources/TVAnarchyCore/VLCTarget.swift @@ -4,7 +4,8 @@ import Foundation /// plum-control-mcp's vlc/client.ts. VLC's native volume is 0..512 (256 = 100%); /// normalized to percent here. The password is resolved from the portable-net-tv /// config, never stored in hosts.json. -public final class VLCTarget: PlayerTarget, MediaLaunchable, Enqueueable, TrackSelectable { +public final class VLCTarget: PlayerTarget, MediaLaunchable, Enqueueable, TrackSelectable, + FullscreenControllable, PlaylistClearable { public let id: String public let name: String public let kind: HostKind = .vlc @@ -158,6 +159,10 @@ public final class VLCTarget: PlayerTarget, MediaLaunchable, Enqueueable, TrackS public func previous() async { _ = await call("command=pl_previous") } public func stop() async { _ = await call("command=pl_stop") } + // MARK: Service tools (Devices tab) + public func toggleFullscreen() async { _ = await call("command=fullscreen") } + public func clearPlaylist() async { _ = await call("command=pl_empty") } + private func numeric(_ v: Any?) -> Double? { if let d = v as? Double { return d } if let i = v as? Int { return Double(i) } diff --git a/Tests/TVAnarchyCoreTests/FleetConfigTests.swift b/Tests/TVAnarchyCoreTests/FleetConfigTests.swift new file mode 100644 index 0000000..e5f06af --- /dev/null +++ b/Tests/TVAnarchyCoreTests/FleetConfigTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import TVAnarchyCore + +/// The fleet registry is additive to hosts.json and `loadOrSeed()` overwrites +/// fleet.json on any decode failure (like HostsConfig), so the schema must +/// round-trip and the seed must model the known home fleet correctly. +final class FleetConfigTests: XCTestCase { + func testSeedModelsHomeFleet() { + let cfg = FleetConfig.seeded() + XCTAssertEqual(cfg.devices.map(\.id), ["black", "apricot", "plum", "phone"]) + + let black = cfg.devices[0] + XCTAssertEqual(black.deviceClass, .server) + XCTAssertTrue(black.alwaysOn) + XCTAssertEqual(black.duties, [.custodyFloor]) + // black runs a *combo*: mpv playback + transmission. + XCTAssertEqual(black.services.map(\.kind), [.mpvIPC, .transmission]) + XCTAssertEqual(black.playbackServices.map(\.kind), [.mpvIPC]) + + // apricot is the secondary always-on seeder + GPU/CPU offload, no playback. + let apricot = cfg.devices[1] + XCTAssertEqual(apricot.services.map(\.kind), [.transmission, .compute]) + XCTAssertTrue(apricot.playbackServices.isEmpty) + + // phone is a pure consumer — never a duty. + let phone = cfg.devices[3] + XCTAssertEqual(phone.deviceClass, .consumer) + XCTAssertTrue(phone.duties.isEmpty) + XCTAssertEqual(phone.services.map(\.kind), [.sink]) + } + + func testPlaybackServicesReferenceHostsByID() { + // Playback services must point at hosts.json ids so the live PlayerTarget + // is reused, not redefined. + let cfg = FleetConfig.seeded() + let blackMpv = cfg.devices[0].services.first { $0.kind == .mpvIPC } + XCTAssertEqual(blackMpv?.playbackHostID, "black") + let plumVlc = cfg.devices[2].services.first { $0.kind == .vlc } + XCTAssertEqual(plumVlc?.playbackHostID, "plum-vlc") + // Fleet-only services carry no playback host. + let transmission = cfg.devices[0].services.first { $0.kind == .transmission } + XCTAssertNil(transmission?.playbackHostID) + } + + func testRoundTripPreservesEverything() throws { + let original = FleetConfig.seeded() + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(FleetConfig.self, from: data) + XCTAssertEqual(decoded.devices, original.devices) + } + + func testServiceKindPlaybackClassification() { + XCTAssertTrue(ServiceKind.mpvIPC.isPlayback) + XCTAssertTrue(ServiceKind.vlc.isPlayback) + XCTAssertTrue(ServiceKind.quicktime.isPlayback) + XCTAssertFalse(ServiceKind.transmission.isPlayback) + XCTAssertFalse(ServiceKind.compute.isPlayback) + XCTAssertFalse(ServiceKind.sink.isPlayback) + } +}