feat(fleet): DevicesView + player target rework (partial Host->Device, keeps HostConfig)
This commit is contained in:
parent
b952d57421
commit
c8515e1cd9
7 changed files with 474 additions and 97 deletions
354
Sources/TVAnarchy/DevicesView.swift
Normal file
354
Sources/TVAnarchy/DevicesView.swift
Normal file
|
|
@ -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<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) }
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
60
Tests/TVAnarchyCoreTests/FleetConfigTests.swift
Normal file
60
Tests/TVAnarchyCoreTests/FleetConfigTests.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue