tv-anarchy/Sources/TVAnarchy/DevicesView.swift

109 lines
5 KiB
Swift

import SwiftUI
import TVAnarchyCore
import AppKit
/// Full device configuration: each device's services (badges), the live
/// connection state of its playback service, plus add/edit/delete and "make
/// active". Persists to devices.json via the controller and reloads.
struct DevicesView: View {
@Bindable var controller: PlayerController
@State private var editing: Device? // edit sheet (existing device)
@State private var adding = false // add sheet
@State private var confirmReset = false
private var devices: [Device] { controller.editableDevices }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Devices").font(.title2).bold()
Spacer()
Button { adding = true } label: { Label("Add Device", systemImage: "plus") }
}
List(devices) { d in
// The playback service shares the device id, so its live state keys
// on d.id. A drive-only device has no playback snapshot.
let snap = controller.snapshot(d.id)
let hasPlayback = d.playbackService != nil
HStack(spacing: 12) {
Circle().fill(hasPlayback ? color(snap.state) : Color.secondary).frame(width: 10)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(d.name).font(.headline)
if hasPlayback, d.id == controller.activeID {
Text("active").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.tint.opacity(0.2), in: Capsule())
}
}
HStack(spacing: 4) {
ForEach(d.services.map(\.kind), id: \.self) { k in
Text(k.label).font(.caption2)
.padding(.horizontal, 5).padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(detail(d)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if hasPlayback {
Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state))
}
Menu {
Button("Make active") { controller.setActive(d.id) }
.disabled(!hasPlayback || d.id == controller.activeID)
Button("Edit…") { editing = d }
Button("Delete", role: .destructive) { controller.deleteDevice(d.id) }
.disabled(devices.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 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 plum + black set?",
isPresented: $confirmReset, titleVisibility: .visible) {
Button("Reset", role: .destructive) { controller.resetDevicesToDefault() }
Button("Cancel", role: .cancel) {}
}
}
/// One-line endpoint summary, leading with the playback service.
private func detail(_ d: Device) -> String {
var parts: [String] = []
for svc in d.services {
switch svc {
case .vlcPlayback(let v): parts.append("\(v.host):\(v.port)")
case .mpvPlayback(let m, _): parts.append(m.endpoints.joined(separator: ", "))
case .quicktimePlayback: parts.append("local")
case .resourcesDrive(let r): parts.append("drive \(r.basePath)")
}
}
return parts.joined(separator: " · ")
}
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" }
}
}