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>
437 lines
17 KiB
Swift
437 lines
17 KiB
Swift
import SwiftUI
|
|
import TVAnarchyCore
|
|
import AppKit
|
|
|
|
/// Dedicated view for the offline cache contents: a live, playable list of items on disk.
|
|
/// Dynamically reflects auto fill (warmup) and cull (budget eviction). Tap to play (forces local player).
|
|
/// Includes mini policy pane so user can tweak ahead/behind/shows/cull/budget without leaving the list.
|
|
struct OfflineCacheView: View {
|
|
@Bindable var offline: OfflineCacheController
|
|
@Bindable var library: LibraryController
|
|
@Bindable var controller: PlayerController
|
|
|
|
@State private var localPolicy = OfflineCachePolicy.defaults
|
|
@State private var searchText = ""
|
|
@State private var localDeviceId: String?
|
|
@State private var confirmDestroy = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Top bar: title + counts + actions
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Offline Cache").font(.title2).bold()
|
|
let p = pinnedItems.count
|
|
let base = "\(filteredItems.count) of \(offline.diskFileCount) · \(OfflineCacheController.formatBytes(filteredBytes))"
|
|
Text(p > 0 ? "\(base) · \(p) pinned" : base)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
Spacer(minLength: 12)
|
|
Button {
|
|
revealCache()
|
|
} label: {
|
|
Label("Reveal", systemImage: "folder")
|
|
}
|
|
.controlSize(.small)
|
|
.help("Open the cache folder in Finder")
|
|
if canPlayAll {
|
|
Button { playAll() } label: {
|
|
Label("Play all", systemImage: "play.fill")
|
|
}
|
|
.controlSize(.small)
|
|
.help("Start a fresh playlist on the local player with these items (newest first within shows)")
|
|
}
|
|
if offline.diskFileCount > 0 {
|
|
Button(role: .destructive) {
|
|
confirmDestroy = true
|
|
} label: {
|
|
Label("Destroy All", systemImage: "trash.fill")
|
|
}
|
|
.controlSize(.small)
|
|
.tint(.red)
|
|
.help("Permanently delete every offline media file (\(OfflineCacheController.formatBytes(offline.diskBytes))) from local disk to free space. Source copies on storage server are unaffected.")
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(.thinMaterial)
|
|
|
|
// Mini settings pane (compact policy + live stats + warm-up)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Auto fill & cull")
|
|
.font(.headline)
|
|
OfflinePolicySection(
|
|
policy: policyBinding,
|
|
offline: offline,
|
|
showWarmupButton: false,
|
|
showDownloadPanel: false,
|
|
compact: true
|
|
)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 4)
|
|
|
|
Divider()
|
|
|
|
// Main content: activity panels (if any) + searchable grouped list
|
|
if offline.cachedItems.isEmpty {
|
|
ContentUnavailableView(
|
|
"Cache is empty",
|
|
systemImage: "internaldrive",
|
|
description: Text("Turn on the offlineCache service for the local device in This Mac, adjust the policy above or in This Mac, then Warm up now.")
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
|
|
if offline.isDownloading {
|
|
Section {
|
|
OfflineDownloadPanel(offline: offline, maxQueueRows: 12)
|
|
.padding(8)
|
|
.background(.quaternary.opacity(0.2), in: RoundedRectangle(cornerRadius: 8))
|
|
} header: {
|
|
sectionHeader("Active downloads")
|
|
}
|
|
}
|
|
if offline.lastCullSummary != nil || offline.planMissingCount > 0 || offline.pinnedMissingCount > 0 {
|
|
Section {
|
|
OfflineCullPanel(offline: offline, maxFileRows: 4)
|
|
.padding(8)
|
|
} header: {
|
|
sectionHeader("Recent fill/cull")
|
|
}
|
|
}
|
|
|
|
if !pinnedItems.isEmpty {
|
|
Section {
|
|
ForEach(pinnedItems) { item in
|
|
row(for: item)
|
|
}
|
|
} header: {
|
|
sectionHeader("Starred (kept from cull) · \(pinnedItems.count)")
|
|
}
|
|
}
|
|
|
|
ForEach(nonPinnedGrouped, id: \.show) { group in
|
|
Section {
|
|
ForEach(group.items) { item in
|
|
row(for: item)
|
|
}
|
|
} header: {
|
|
sectionHeader("\(group.show) · \(group.items.count)")
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 20)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Offline")
|
|
.searchable(text: $searchText, placement: .toolbar, prompt: "Filter by name or show")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
HostSelector(controller: controller, compact: true)
|
|
}
|
|
}
|
|
.onAppear {
|
|
reloadPolicy()
|
|
offline.refreshDiskStats()
|
|
}
|
|
.task {
|
|
_ = await Task.detached(priority: .utility) { DownloadsIndex.shared.refresh() }.value
|
|
offline.refreshDiskStats()
|
|
}
|
|
.confirmationDialog(
|
|
"Destroy all offline media?",
|
|
isPresented: $confirmDestroy,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Destroy \(OfflineCacheController.formatBytes(offline.diskBytes))", role: .destructive) {
|
|
Task {
|
|
_ = await offline.destroyAllOfflineMedia(policy: localPolicy)
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This permanently deletes all cached episode files from the local offline folder (\(OfflineCacheController.destRoot(for: localPolicy).path)). The source files on the storage server (black) remain untouched and can be re-cached later with Warm up. This cannot be undone.")
|
|
}
|
|
}
|
|
|
|
// MARK: filtering + grouping (dynamic as cachedItems mutates on fill/cull)
|
|
|
|
private var filteredItems: [OfflineCachedFile] {
|
|
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !q.isEmpty else { return offline.cachedItems }
|
|
return offline.cachedItems.filter { it in
|
|
it.name.lowercased().contains(q) || it.showDir.lowercased().contains(q)
|
|
}
|
|
}
|
|
|
|
private var filteredBytes: Int64 {
|
|
filteredItems.reduce(0) { $0 + $1.size }
|
|
}
|
|
|
|
private var groupedFiltered: [(show: String, items: [OfflineCachedFile])] {
|
|
let dict = Dictionary(grouping: filteredItems, by: \.showDir)
|
|
return dict.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
|
.map { k in
|
|
(k, dict[k]!.sorted { $0.modifiedAt > $1.modifiedAt })
|
|
}
|
|
}
|
|
|
|
private var pinnedItems: [OfflineCachedFile] {
|
|
let pinSet = Set(localPolicy.pinned.map { $0.lowercased() })
|
|
return filteredItems.filter { pinSet.contains($0.name.lowercased()) }
|
|
.sorted { $0.modifiedAt > $1.modifiedAt }
|
|
}
|
|
|
|
private var nonPinnedGrouped: [(show: String, items: [OfflineCachedFile])] {
|
|
let pinSet = Set(localPolicy.pinned.map { $0.lowercased() })
|
|
let non = filteredItems.filter { !pinSet.contains($0.name.lowercased()) }
|
|
let dict = Dictionary(grouping: non, by: \.showDir)
|
|
return dict.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
|
.map { k in (k, dict[k]!.sorted { $0.modifiedAt > $1.modifiedAt }) }
|
|
}
|
|
|
|
private var canPlayAll: Bool { !filteredItems.isEmpty }
|
|
|
|
private var localDevice: DeviceConfig? {
|
|
controller.editableDevices.first { $0.kind.isLocal }
|
|
}
|
|
|
|
private func reloadPolicy() {
|
|
guard let d = localDevice else { return }
|
|
localPolicy = d.resolvedOfflinePolicy()
|
|
localDeviceId = d.id
|
|
}
|
|
|
|
private func isPinned(_ item: OfflineCachedFile) -> Bool {
|
|
let pinSet = Set(localPolicy.pinned.map { $0.lowercased() })
|
|
return pinSet.contains(item.name.lowercased())
|
|
}
|
|
|
|
private func setPinned(_ names: [String]) {
|
|
let sorted = Array(Set(names)).sorted()
|
|
var updated = localPolicy
|
|
updated.pinned = sorted
|
|
localPolicy = updated
|
|
if let id = localDeviceId {
|
|
controller.updateOfflinePolicy(deviceId: id, updated)
|
|
}
|
|
}
|
|
|
|
private func pin(_ item: OfflineCachedFile) {
|
|
var names = localPolicy.pinned
|
|
if !names.contains(item.name) { names.append(item.name) }
|
|
setPinned(names)
|
|
}
|
|
|
|
private func unpin(_ item: OfflineCachedFile) {
|
|
let names = localPolicy.pinned.filter { $0 != item.name && $0.lowercased() != item.name.lowercased() }
|
|
setPinned(names)
|
|
}
|
|
|
|
private func togglePin(_ item: OfflineCachedFile) {
|
|
isPinned(item) ? unpin(item) : pin(item)
|
|
}
|
|
|
|
private func sourcePath(for item: OfflineCachedFile) -> String? {
|
|
let lower = item.name.lowercased()
|
|
for s in library.shows {
|
|
if let ep = s.episodes.first(where: { (($0.path as NSString).lastPathComponent.lowercased()) == lower }) {
|
|
return ep.path
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markSourceBroken(_ storagePath: String) async {
|
|
let (ok, msg) = await DevicesConfig.markStorageFileBroken(MediaPaths.toRemote(storagePath))
|
|
await MainActor.run {
|
|
controller.note(ok ? msg : "Mark broken failed: \(msg)")
|
|
}
|
|
}
|
|
|
|
private var policyBinding: Binding<OfflineCachePolicy> {
|
|
Binding(
|
|
get: { localPolicy },
|
|
set: { new in
|
|
localPolicy = new
|
|
if let id = localDeviceId {
|
|
controller.updateOfflinePolicy(deviceId: id, new)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@ViewBuilder private func sectionHeader(_ title: String) -> some View {
|
|
Text(title.uppercased())
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 4)
|
|
}
|
|
|
|
// MARK: row + actions
|
|
|
|
private func row(for item: OfflineCachedFile) -> some View {
|
|
HStack(spacing: 10) {
|
|
if isPinned(item) {
|
|
Image(systemName: "star.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.yellow)
|
|
.frame(width: 20)
|
|
} else {
|
|
Image(systemName: "play.rectangle.on.rectangle.fill")
|
|
.font(.body)
|
|
.foregroundStyle(.tint)
|
|
.frame(width: 20)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(item.name)
|
|
.font(.callout)
|
|
.lineLimit(1)
|
|
Text(item.showDir)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
VStack(alignment: .trailing, spacing: 1) {
|
|
Text(OfflineCacheController.formatBytes(item.size))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.tertiary)
|
|
Text(item.modifiedAt, style: .relative)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
|
|
Button(action: { togglePin(item) }) {
|
|
Image(systemName: isPinned(item) ? "star.fill" : "star")
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.tint(isPinned(item) ? .yellow : .secondary)
|
|
.help(isPinned(item)
|
|
? "Starred — kept from auto-cull and restored if missing. Click to allow culling."
|
|
: "Star to keep this file from being culled (and restore it if it goes missing)")
|
|
|
|
Button(action: { play(item) }) {
|
|
Image(systemName: "play.fill")
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.tint(.primary)
|
|
.help("Play immediately on local player")
|
|
|
|
Button(action: { delete(item) }) {
|
|
Image(systemName: "trash")
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.tint(.red)
|
|
.help("Cull now — delete this file from the local offline cache. Source on storage is untouched.")
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { play(item) }
|
|
.contextMenu {
|
|
Button("Play", systemImage: "play.fill") { play(item) }
|
|
if controller.active is Enqueueable {
|
|
Button("Play from here as playlist", systemImage: "text.line.first.and.arrowtriangle.forward") {
|
|
playFromHere(item)
|
|
}
|
|
}
|
|
Divider()
|
|
Button("Reveal in Finder", systemImage: "folder") {
|
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: item.localPath)])
|
|
}
|
|
if isPinned(item) {
|
|
Button("Unstar (allow future cull)", systemImage: "star.slash") {
|
|
unpin(item)
|
|
}
|
|
} else {
|
|
Button("Star (keep from culling)", systemImage: "star") {
|
|
pin(item)
|
|
}
|
|
.help("Protect this file from auto-cull; it will also be restored if missing")
|
|
}
|
|
Button("Cull now (delete from offline cache)", systemImage: "trash", role: .destructive) {
|
|
delete(item)
|
|
}
|
|
.help("Remove this file only (auto-cull will manage the rest)")
|
|
if let sp = sourcePath(for: item) {
|
|
Divider()
|
|
Button(role: .destructive) {
|
|
Task { await markSourceBroken(sp) }
|
|
} label: {
|
|
Label("Mark source as broken (governor skip)", systemImage: "exclamationmark.octagon.fill")
|
|
}
|
|
.help("Mark the original file on storage broken so governor keeper/watch skips it. (The cached copy here can still be deleted separately.)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func play(_ item: OfflineCachedFile) {
|
|
// The offline list is the source of truth for items safe to play locally.
|
|
if let dev = localDevice, controller.activeID != dev.id {
|
|
controller.setActive(dev.id)
|
|
}
|
|
controller.launch(.file(path: item.localPath), series: item.showDir)
|
|
}
|
|
|
|
private func playAll() {
|
|
let paths = groupedFiltered.flatMap { $0.items.map(\.localPath) }
|
|
guard !paths.isEmpty else { return }
|
|
if let dev = localDevice, controller.activeID != dev.id {
|
|
controller.setActive(dev.id)
|
|
}
|
|
if let _ = controller.active as? Enqueueable {
|
|
controller.enqueuePlaylist(paths)
|
|
} else if let first = paths.first {
|
|
controller.launch(.file(path: first), series: nil)
|
|
}
|
|
}
|
|
|
|
private func playFromHere(_ start: OfflineCachedFile) {
|
|
var started = false
|
|
var tail: [String] = []
|
|
for g in groupedFiltered {
|
|
for it in g.items {
|
|
if it.id == start.id { started = true }
|
|
if started { tail.append(it.localPath) }
|
|
}
|
|
}
|
|
guard !tail.isEmpty else { return }
|
|
if let dev = localDevice, controller.activeID != dev.id {
|
|
controller.setActive(dev.id)
|
|
}
|
|
if let _ = controller.active as? Enqueueable {
|
|
controller.enqueuePlaylist(tail)
|
|
} else if let first = tail.first {
|
|
controller.launch(.file(path: first), series: start.showDir)
|
|
}
|
|
}
|
|
|
|
private func delete(_ item: OfflineCachedFile) {
|
|
try? FileManager.default.removeItem(atPath: item.localPath)
|
|
if isPinned(item) {
|
|
unpin(item) // explicit delete clears the keep intent
|
|
}
|
|
// Immediate visual update; periodic reconcile or next policy run will re-scan anyway.
|
|
offline.refreshDiskStats()
|
|
// DownloadsIndex will catch up on next library refresh or manual; direct localPath plays still work.
|
|
}
|
|
|
|
private func revealCache() {
|
|
let root = OfflineCacheController.destRoot(for: localPolicy)
|
|
NSWorkspace.shared.open(root)
|
|
}
|
|
}
|