339 lines
14 KiB
Swift
339 lines
14 KiB
Swift
|
|
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("F7–F9 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" }
|
|||
|
|
}
|
|||
|
|
}
|