tv-anarchy/Sources/TVAnarchy/DeviceView.swift

339 lines
14 KiB
Swift
Raw Permalink Normal View History

import SwiftUI
import TVAnarchyCore
import AppKit
/// Holistic state + management for the player on this machine. The Devices tab
/// handles the device registry for the install; this tab is where local playback, services, and
/// offline cache live together.
struct DeviceView: View {
@Bindable var controller: PlayerController
@Bindable var library: LibraryController
@Bindable var offline: OfflineCacheController
var streamability: StreamabilityMonitor
@State private var editing = false
@State private var localPolicy = OfflineCachePolicy.defaults
@State private var localStreamPolicy = StreamPolicy.defaults
@State private var confirmDestroyOffline = false
private var device: DeviceConfig? { controller.editableDevices.first { $0.kind.isLocal } }
var body: some View {
ScrollView {
if let device {
deviceContent(device)
} else {
ContentUnavailableView(
"No local player",
systemImage: "laptopcomputer",
description: Text("Add a VLC or QuickTime device in the Devices tab.")
)
.padding(40)
}
}
.navigationTitle("This Mac")
.onAppear { reloadPolicy() }
.sheet(isPresented: $editing) {
if let device {
DeviceEditView(existing: device) { controller.upsertDevice($0); reloadPolicy() }
}
}
}
@ViewBuilder
private func deviceContent(_ device: DeviceConfig) -> some View {
let snap = controller.snapshot(device.id)
VStack(alignment: .leading, spacing: 20) {
header(device, snap)
if offline.isDownloading {
downloadingCard
}
playbackCard(device, snap)
if device.services.stream, library.playbackMode == .stream {
streamCard(device)
}
mediaKeysCard
servicesCard(device)
if device.services.offlineCache {
offlineCard(device)
}
configCard(device)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: sections
private func header(_ device: DeviceConfig, _ snap: PlayerController.Snapshot) -> some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: device.type.icon)
.font(.largeTitle)
.foregroundStyle(.tint)
.frame(width: 44)
VStack(alignment: .leading, spacing: 4) {
Text(device.name).font(.title2).bold()
Text("This Mac · \(device.type.label) · \(device.kind.label)")
.font(.subheadline).foregroundStyle(.secondary)
HStack(spacing: 8) {
Label(stateLabel(snap.state), systemImage: "circle.fill")
.font(.caption)
.foregroundStyle(color(snap.state))
if device.id == controller.activeID {
Text("active player").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.tint.opacity(0.2), in: Capsule())
}
if offline.isDownloading {
Text("downloading").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.blue.opacity(0.2), in: Capsule())
}
}
}
Spacer()
Button("Edit…") { editing = true }
.help("Edit device name, services, backend, and offline policy")
}
}
private var downloadingCard: some View {
card("Downloading") {
OfflineDownloadPanel(offline: offline, maxQueueRows: 50)
}
}
private func streamCard(_ device: DeviceConfig) -> some View {
card("Streaming") {
StreamabilityIndicator(sample: streamability.sample)
StreamPolicySection(
policy: streamPolicyBinding(deviceId: device.id),
episodeDuration: controller.activeSnapshot.status.duration)
}
}
private func playbackCard(_ device: DeviceConfig, _ snap: PlayerController.Snapshot) -> some View {
card("Playback") {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
gridRow("Backend", "\(device.kind.label) · \(endpoint(device))")
gridRow("Now", playbackLine(snap))
gridRow("Connection", connectionLine(snap))
}
if device.id != controller.activeID, device.services.stream {
Button("Make active player") { controller.setActive(device.id) }
.controlSize(.small)
.help("Send playback commands to this Mac's player")
}
}
}
private var mediaKeysCard: some View {
card("Media keys") {
Toggle("Forward media keys", isOn: $library.forwardMediaKeys)
.toggleStyle(.switch)
.help("Play/pause, next, previous, and seek from hardware keys, Control Center, lock screen, and AirPods")
.onChange(of: library.forwardMediaKeys) { controller.applyMediaKeyForwarding() }
Text("F7F9 and the system transport drive TVAnarchy playback and update Now Playing. Off lets those controls pass through to other apps.")
.font(.caption).foregroundStyle(.secondary)
Toggle("Forward volume keys", isOn: $library.forwardVolumeKeys)
.toggleStyle(.switch)
.help("Volume up/down adjust the active player while something is playing")
.onChange(of: library.forwardVolumeKeys) { controller.applyMediaKeyForwarding() }
Text("F10/F11 adjust the player volume while actively playing; when idle, or with this off, they change the Mac's system volume.")
.font(.caption).foregroundStyle(.secondary)
}
}
private func servicesCard(_ device: DeviceConfig) -> some View {
card("Services") {
Text(servicesSummary(device.services))
.font(.callout)
Text("Toggle duties in Edit — stream and offline cache are actuated today.")
.font(.caption).foregroundStyle(.secondary)
}
}
private func offlineCard(_ device: DeviceConfig) -> some View {
card("Offline cache") {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 16) {
statTile("On disk", "\(offline.diskFileCount) episodes",
subtitle: OfflineCacheController.formatBytes(offline.diskBytes))
if let budget = budgetTile(policy: localPolicy) {
statTile("Budget", budget.title, subtitle: budget.subtitle)
}
}
if offline.lastCullSummary != nil || offline.planMissingCount > 0 {
OfflineCullPanel(offline: offline)
}
if !offline.isDownloading, let status = offline.status {
Text(status).font(.caption).foregroundStyle(.secondary)
}
OfflinePolicySection(
policy: policyBinding(deviceId: device.id),
offline: offline,
showDownloadPanel: false)
}
}
}
private func configCard(_ device: DeviceConfig) -> some View {
card("Configuration") {
HStack(spacing: 12) {
Button("Reveal cache folder") {
let root = OfflineCacheController.destRoot(for: localPolicy)
NSWorkspace.shared.open(root)
}
.help("Open the offline episode cache in Finder")
Button("Reveal devices.json") {
NSWorkspace.shared.activateFileViewerSelecting([DevicesConfig.configURL()])
}
.help("Show devices.json where install device registry and offline policy are stored")
if offline.diskFileCount > 0 {
Button(role: .destructive) {
confirmDestroyOffline = true
} label: {
Label("Destroy offline media", systemImage: "trash")
}
.controlSize(.mini)
.tint(.red)
.help("1-click destroy all local offline media to free space (\(OfflineCacheController.formatBytes(offline.diskBytes)))")
}
Spacer()
}
.controlSize(.small)
Text("App API: 127.0.0.1:\(AppLocalAPI.port) — MCP reads/writes settings and offline state here.")
.font(.caption.monospaced()).foregroundStyle(.tertiary)
Text("Device id: \(device.id)")
.font(.caption.monospaced()).foregroundStyle(.tertiary).textSelection(.enabled)
}
.confirmationDialog(
"Destroy all offline media for this Mac?",
isPresented: $confirmDestroyOffline,
titleVisibility: .visible
) {
Button("Destroy \(OfflineCacheController.formatBytes(offline.diskBytes))", role: .destructive) {
Task { _ = await offline.destroyAllOfflineMedia(policy: localPolicy) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Deletes the entire local offline cache dir. Sources on storage server unaffected. Irreversible for local copies.")
}
}
private struct BudgetTile { let title: String; let subtitle: String }
private func budgetTile(policy: OfflineCachePolicy) -> BudgetTile? {
let root = OfflineCacheController.destRoot(for: policy).path
guard policy.cullEnabled,
let total = OfflineCacheController.storageTotalBytes(at: root) else { return nil }
let budget = OfflineCacheController.budgetBytes(policy: policy)
let free = OfflineCacheController.storageFreeBytes(at: root)
var sub = "\(policy.budgetPercent)% of \(OfflineCacheController.formatBytes(total))"
if let free { sub += " · \(OfflineCacheController.formatBytes(free)) free" }
return BudgetTile(title: OfflineCacheController.formatBytes(budget), subtitle: sub)
}
private func statTile(_ label: String, _ value: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.headline)
Text(subtitle).font(.caption2).foregroundStyle(.tertiary)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8))
}
// MARK: helpers
private func card<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title).font(.headline)
content()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10))
}
private func gridRow(_ label: String, _ value: String) -> some View {
GridRow {
Text(label).font(.caption).foregroundStyle(.secondary)
.gridColumnAlignment(.trailing)
Text(value).font(.caption).textSelection(.enabled)
}
}
private func policyBinding(deviceId: String) -> Binding<OfflineCachePolicy> {
Binding(
get: { localPolicy },
set: { new in
localPolicy = new
controller.updateOfflinePolicy(deviceId: deviceId, new)
}
)
}
private func streamPolicyBinding(deviceId: String) -> Binding<StreamPolicy> {
Binding(
get: { localStreamPolicy },
set: { new in
localStreamPolicy = new
controller.updateStreamPolicy(deviceId: deviceId, new)
streamability.refreshActivation()
}
)
}
private func reloadPolicy() {
guard let device else { return }
localPolicy = device.resolvedOfflinePolicy()
localStreamPolicy = device.resolvedStreamPolicy()
}
private func endpoint(_ d: DeviceConfig) -> String {
switch d.kind {
case .vlc: d.vlc.map { "\($0.host):\($0.port)" } ?? ""
case .quicktime: "local"
default: ""
}
}
private func playbackLine(_ snap: PlayerController.Snapshot) -> String {
if snap.status.playing, let t = snap.status.title {
let mode = snap.status.paused == true ? "paused" : "playing"
return "\(mode): \(t)"
}
return snap.state == .connected ? "idle" : stateLabel(snap.state)
}
private func connectionLine(_ snap: PlayerController.Snapshot) -> String {
var line = stateLabel(snap.state)
if snap.status.playing, let t = snap.status.title {
line += " · \(snap.status.paused == true ? "paused" : "playing"): \(t)"
} else if snap.state == .connected {
line += " · idle"
}
return line
}
private func servicesSummary(_ s: DeviceServices) -> String {
var on: [String] = []
if s.stream { on.append("stream") }
if s.offlineCache { on.append("offline cache") }
if s.ttlSeed { on.append("TTL seed (planned)") }
if s.custody { on.append("custody (planned)") }
if s.publicSwarmFace { on.append("swarm face (planned)") }
if s.f2fRelay { on.append("relay (planned)") }
if s.meshAnchor { on.append("mesh anchor (planned)") }
return on.isEmpty ? "no services enabled" : on.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" }
}
}