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

158 lines
No EOL
5.2 KiB
Swift

import SwiftUI
import TVAnarchyCore
/// Shared offline download UI: summary, overall percent, and queued episode list.
struct OfflineDownloadPanel: View {
@Bindable var offline: OfflineCacheController
var compact = false
var maxQueueRows = 8
private var active: Bool { offline.isDownloading }
var body: some View {
if active {
VStack(alignment: .leading, spacing: compact ? 6 : 10) {
summaryRow
progressRow
if !compact, !offline.downloadQueue.isEmpty { queueList }
else if !compact, offline.downloadQueue.isEmpty, let status = offline.status {
Text(status).font(.caption).foregroundStyle(.secondary)
}
}
.modifier(OfflineDownloadPanelChrome(compact: compact))
}
}
private var summaryRow: some View {
HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
if compact {
Text("Offline cache")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(offline.downloadSummaryLine)
.font(compact ? .callout : .headline)
.lineLimit(compact ? 2 : 2)
}
}
}
@ViewBuilder private var progressRow: some View {
if let overall = offline.queueProgress {
ProgressView(value: overall)
Text("Overall \(Int(overall * 100))%")
.font(.caption)
.foregroundStyle(.secondary)
} else if let file = offline.downloadingProgress {
ProgressView(value: file)
Text("\(Int(file * 100))%")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ProgressView()
}
}
private var queueList: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Queue (\(offline.downloadQueue.count))")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 4) {
let rows = offline.downloadQueue.prefix(maxQueueRows)
ForEach(Array(rows)) { item in
queueRow(item)
}
}
}
.frame(maxHeight: 220)
let extra = offline.downloadQueue.count - min(offline.downloadQueue.count, maxQueueRows)
if extra > 0 {
Text("+\(extra) more…")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
private func queueRow(_ item: OfflineQueueItem) -> some View {
HStack(spacing: 8) {
queueIcon(item.state)
.frame(width: 14)
VStack(alignment: .leading, spacing: 1) {
Text(item.name)
.font(.caption)
.lineLimit(1)
Text(item.show)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
queueTrailing(item)
}
.opacity(item.state == .pending ? 0.55 : 1)
}
@ViewBuilder private func queueIcon(_ state: OfflineQueueState) -> some View {
switch state {
case .pending:
Image(systemName: "circle")
.font(.caption2)
.foregroundStyle(.tertiary)
case .downloading:
ProgressView().controlSize(.mini)
case .done:
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
case .failed:
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
}
@ViewBuilder private func queueTrailing(_ item: OfflineQueueItem) -> some View {
switch item.state {
case .downloading:
if let p = item.progress {
Text("\(Int(p * 100))%")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
} else {
Text("")
.font(.caption2)
.foregroundStyle(.secondary)
}
case .done:
Text("done")
.font(.caption2)
.foregroundStyle(.secondary)
case .failed:
Text("failed")
.font(.caption2)
.foregroundStyle(.orange)
case .pending:
Text("queued")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
private struct OfflineDownloadPanelChrome: ViewModifier {
let compact: Bool
func body(content: Content) -> some View {
if compact { content }
else {
content
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
}
}
}