tv-anarchy/Sources/TVAnarchy/DeviceView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

339 lines
No EOL
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" }
}
}