tv-anarchy/Sources/TVAnarchy/OfflinePolicySection.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

149 lines
No EOL
7.8 KiB
Swift

import SwiftUI
import TVAnarchyCore
/// Warmup + culling controls for a device's offline-cache policy.
struct OfflinePolicySection: View {
@Binding var policy: OfflineCachePolicy
var offline: OfflineCacheController?
var showWarmupButton = true
/// When false, parent (e.g. Device page) owns the download panel.
var showDownloadPanel = true
/// Compact mode for embedding in the Offline list view (mini settings pane).
var compact = false
var body: some View {
VStack(alignment: .leading, spacing: 14) {
if compact {
compactControls
} else {
warmupBlock
Divider()
cullingBlock
}
if showWarmupButton, let offline, !compact {
HStack(spacing: 16) {
LabeledContent("On disk") {
Text("\(offline.diskFileCount) episodes · \(OfflineCacheController.formatBytes(offline.diskBytes))")
}
Button("Warm up now") { Task { await offline.cacheNow(policy: policy) } }
.disabled(offline.caching)
.help("Start rsync immediately with the current policy values (bypasses the 60s debounce)")
}
if showDownloadPanel, offline.isDownloading {
OfflineDownloadPanel(offline: offline, maxQueueRows: 6)
} else if let status = offline.status {
Text(status).font(.caption).foregroundStyle(.secondary)
}
}
}
.onAppear { offline?.refreshDiskStats() }
}
private var warmupBlock: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Warmup").font(.headline)
Toggle("Warm up on launch", isOn: $policy.warmupEnabled)
.toggleStyle(.switch).fixedSize()
.help("Rsync the warmup window when the app starts — saved in devices.json")
Text("Rsync the episode window for recent shows from black. Setting changes take ~60s before they affect auto warmup, play-triggered fetch, or culling. \"Warm up now\" uses the current values immediately.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
HStack(spacing: 16) {
Stepper("Ahead: \(policy.episodesAhead)", value: $policy.episodesAhead, in: 1...10)
.fixedSize().help("Episodes from your resume point forward (inclusive)")
Stepper("Behind: \(policy.episodesBehind)", value: $policy.episodesBehind, in: 0...5)
.fixedSize().help("Episodes before your resume point")
}
Stepper("Shows: \(policy.shows)", value: $policy.shows, in: 1...20)
.fixedSize()
.help("How many recent shows to keep cached around your resume points")
Picker("Pick shows from", selection: $policy.fromContinueWatching) {
Text("Continue Watching").tag(true)
Text("Recently Added").tag(false)
}.pickerStyle(.segmented).fixedSize()
.help("Which rail seeds the show list for warmup and culling")
}
}
private var cullingBlock: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Culling").font(.headline)
Toggle("Cull oldest when over budget", isOn: $policy.cullEnabled)
.toggleStyle(.switch).fixedSize()
.help("Delete oldest cached episodes when over the storage budget")
Text("Budget is a percentage of the drive's total storage where the cache lives. When over budget, oldest cached episodes are removed and listed on this page; the app rechecks every 10 minutes and refetches missing warmup-window episodes when space allows.")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
Stepper("Keep free: \(policy.reserveFreeGB) GB",
value: $policy.reserveFreeGB, in: 1...50, step: 1)
.fixedSize()
.help("Never download or retain cache past the point where less than this much space stays free on the drive")
if policy.cullEnabled {
Stepper("Drive storage: \(policy.budgetPercent)%",
value: $policy.budgetPercent, in: 5...50, step: 1)
.fixedSize()
.help("Maximum share of the cache drive's total capacity for offline episodes")
if let line = cullingBudgetLine {
Text(line)
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}
}
}
}
private var cullingBudgetLine: String? {
let root = OfflineCacheController.destRoot(for: policy).path
guard let total = OfflineCacheController.storageTotalBytes(at: root) else { return nil }
let budget = OfflineCacheController.budgetBytes(policy: policy)
var line = "Cap ≈ \(OfflineCacheController.formatBytes(budget)) (\(policy.budgetPercent)% of \(OfflineCacheController.formatBytes(total)) at \(root))"
line += " · reserve \(policy.reserveFreeGB) GB free"
if let free = OfflineCacheController.storageFreeBytes(at: root) {
line += " · \(OfflineCacheController.formatBytes(free)) free now"
}
return line
}
/// Mini horizontal controls for the dedicated Offline list view (dynamic fill/cull settings at a glance).
private var compactControls: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Toggle("Warm up", isOn: $policy.warmupEnabled)
.toggleStyle(.switch)
.controlSize(.small)
.help("Auto rsync the window on launch and after plays")
Toggle("Cull", isOn: $policy.cullEnabled)
.toggleStyle(.switch)
.controlSize(.small)
.help("Evict oldest outside the window when over budget")
Spacer()
if let offline {
Text("\(offline.diskFileCount) files · \(OfflineCacheController.formatBytes(offline.diskBytes))")
.font(.caption2).foregroundStyle(.secondary)
}
}
HStack(spacing: 8) {
Stepper("Ahead \(policy.episodesAhead)", value: $policy.episodesAhead, in: 1...10)
.controlSize(.small).help("Episodes ahead of resume")
Stepper("Behind \(policy.episodesBehind)", value: $policy.episodesBehind, in: 0...5)
.controlSize(.small).help("Episodes before resume")
Stepper("Shows \(policy.shows)", value: $policy.shows, in: 1...20)
.controlSize(.small).help("How many shows to window")
if policy.cullEnabled {
Stepper("\(policy.budgetPercent)%", value: $policy.budgetPercent, in: 5...50)
.controlSize(.small).help("Budget % of drive")
}
Button("Warm up now") { Task { await offline?.cacheNow(policy: policy) } }
.disabled(offline?.caching ?? true)
.controlSize(.small)
Spacer()
}
if let offline, offline.planMissingCount > 0 {
Text("\(offline.planMissingCount) missing from current plan — will fill on next reconcile")
.font(.caption2).foregroundStyle(.secondary)
}
Text("Changes debounce ~60s. List below updates live as items fill or cull.")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(8)
.background(.quaternary.opacity(0.25), in: RoundedRectangle(cornerRadius: 6))
}
}