tv-anarchy/Sources/TVAnarchy/OfflineCacheView.swift
Natalie e532fe14bc feat(offline): star in place — keep files in their show group
Starring no longer relocates a file into a separate 'Starred' section; every
item stays in its own show group and the row's star button just toggles on/off
(filled-yellow vs outline) as the keep-from-cull indicator. Show headers show a
'★N' badge for how many in that group are starred. Drops the leading star-swap
and the now-unused nonPinnedGrouped grouping.

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

417 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")
}
}
// Items stay in their own show group regardless of star
// state starring just toggles the star on the row (and
// protection from cull), it doesn't relocate the file.
ForEach(groupedFiltered, id: \.show) { group in
Section {
ForEach(group.items) { item in
row(for: item)
}
} header: {
let starred = group.items.filter(isPinned).count
sectionHeader("\(group.show) · \(group.items.count)"
+ (starred > 0 ? " · ★\(starred)" : ""))
}
}
}
.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 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) {
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)
}
}