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>
149 lines
No EOL
7.8 KiB
Swift
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))
|
|
}
|
|
|
|
} |