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

339 lines
15 KiB
Swift

import SwiftUI
import TVAnarchyCore
/// Downloads: a live transmission transfer dashboard aggregate rates + counts,
/// then Downloading / Seeding / Completed sections with per-torrent actions.
/// (Torrent search moved to its own Search tab.) Polls every few seconds.
struct DownloadsView: View {
@Bindable var downloads: DownloadsController
@Bindable var player: PlayerController
var body: some View {
List {
Section { aggregateRow }
if let err = downloads.transfersError {
CopyableText(text: err, systemImage: "bolt.horizontal.circle")
}
transferSection("Downloading", downloads.downloadingSorted)
transferSection("Seeding", downloads.seeding)
transferSection("Completed", downloads.completed)
if downloads.transfers.isEmpty && downloads.transfersError == nil {
Text("No transfers. Find something in the Search tab and add it here.")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Downloads")
.toolbar { ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) } }
.overlay(alignment: .bottom) {
if let action = downloads.lastAction {
Text(action).font(.callout).padding(.horizontal, 14).padding(.vertical, 10)
.background(.thinMaterial, in: Capsule()).padding(.bottom, 16)
.onTapGesture { copyToClipboard(action) }.help("Click to copy")
}
}
.sheet(isPresented: Binding(
get: { downloads.detailFor != nil },
set: { if !$0 { downloads.closeDetail() } }
)) {
TransferDetailSheet(downloads: downloads)
}
}
private var aggregateRow: some View {
HStack(spacing: 22) {
stat("arrow.down", DownloadsController.rate(downloads.totalDownRate), .blue)
stat("arrow.up", DownloadsController.rate(downloads.totalUpRate), .green)
stat("arrow.down.circle", "\(downloads.downloading.count) active", .secondary)
stat("seal", "\(downloads.seeding.count) seeding", .secondary)
if downloads.attentionCount > 0 {
stat("exclamationmark.triangle.fill", "\(downloads.attentionCount) need attention", .orange)
}
Spacer()
}
}
private func stat(_ icon: String, _ value: String, _ color: Color) -> some View {
Label(value, systemImage: icon).font(.callout.monospacedDigit()).foregroundStyle(color)
}
@ViewBuilder private func transferSection(_ title: String, _ rows: [TorrentRow]) -> some View {
if !rows.isEmpty {
Section("\(title) · \(rows.count)") {
ForEach(rows) { row in
TransferRowView(row: row, health: downloads.health(row),
onInspect: { Task { await downloads.loadDetail(for: row.id) } },
onRemoveKeep: { Task { await downloads.remove(row, deleteData: false) } },
onRemoveData: { Task { await downloads.remove(row, deleteData: true) } })
}
}
}
}
}
/// One transfer row: name + status, then either a completion time (done) or a
/// live progress bar with rate/ETA (active).
private struct TransferRowView: View {
let row: TorrentRow
let health: TransferHealth
let onInspect: () -> Void
let onRemoveKeep: () -> Void
let onRemoveData: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(row.name).lineLimit(1).font(.callout)
Spacer()
statusPill
Menu {
Button("Inspect / fix…", action: onInspect)
.help("Show per-tracker stats, peers, error details and recovery actions")
Divider()
Button("Remove (keep data)", action: onRemoveKeep)
.help("Remove from transmission but leave the downloaded files on disk")
Button("Remove and delete data", role: .destructive, action: onRemoveData)
.help("Remove from transmission and delete the files from black")
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize()
}
.contentShape(Rectangle())
.onTapGesture(count: 2, perform: onInspect)
if row.isComplete {
HStack(spacing: 10) {
if let at = row.completedAt {
Label {
Text("completed ") + Text(at, format: .relative(presentation: .named))
} icon: { Image(systemName: "checkmark.circle.fill") }
.foregroundStyle(.green)
}
Text(row.sizeText)
Spacer()
if !row.upText.isEmpty { Text("\(row.upText)") }
}
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
} else {
ProgressView(value: row.progress)
HStack(spacing: 10) {
Text("\(Int(row.progress * 100))%")
Text(row.sizeText)
if !row.downText.isEmpty { Text("\(row.downText)") }
if !row.etaText.isEmpty { Text("ETA \(row.etaText)") }
Spacer()
}
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
.padding(.vertical, 3)
}
private var statusPill: some View {
Text(pillLabel)
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(pillColor.opacity(0.2), in: Capsule())
.foregroundStyle(pillColor)
}
/// Health-aware label: surfaces the trouble states, else the plain status.
private var pillLabel: String {
switch health {
case .dead: "Stuck · 0 peers"
case .stalled: "Stalled"
case .errored: "Error"
default: row.statusLabel
}
}
private var pillColor: Color {
switch health {
case .errored, .dead: .red
case .stalled, .checking: .orange
case .downloading: .blue
case .seeding, .done: .green
case .queued, .stopped: .secondary
}
}
}
/// Per-torrent debug + advance sheet: the "why" (error, peers, per-tracker announce
/// results) plus the recovery actions (reannounce / verify / pause-resume). Opened
/// from a transfer's "Inspect / fix" menu or a double-click.
private struct TransferDetailSheet: View {
@Bindable var downloads: DownloadsController
var body: some View {
VStack(alignment: .leading, spacing: 14) {
header
if downloads.detailLoading && downloads.detail == nil {
HStack { ProgressView().controlSize(.small); Text("Loading…").foregroundStyle(.secondary) }
} else if let err = downloads.detailError, downloads.detail == nil {
CopyableText(text: err, systemImage: "bolt.horizontal.circle")
} else if let d = downloads.detail {
body(for: d)
}
Spacer(minLength: 0)
}
.padding(20)
.frame(width: 460, height: 460)
}
private var header: some View {
HStack {
Text(downloads.detail?.name ?? "Transfer").font(.headline).lineLimit(2)
Spacer()
Button { downloads.closeDetail() } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.borderless).foregroundStyle(.secondary)
.help("Close the transfer detail sheet")
}
}
@ViewBuilder private func body(for d: TorrentDetail) -> some View {
// Status + live numbers.
HStack(spacing: 16) {
stat("\(Int(d.progress * 100))%", "complete")
stat(d.peersText, "peers")
if d.rateDownload > 0 { stat(DownloadsController.rate(d.rateDownload), "down") }
if d.rateUpload > 0 { stat(DownloadsController.rate(d.rateUpload), "up") }
}
if d.hasError {
CopyableText(text: d.errorString, systemImage: "exclamationmark.triangle.fill")
}
// Per-tracker announce picture the "why no peers".
if !d.trackerStats.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Trackers").font(.caption).foregroundStyle(.secondary)
ForEach(d.trackerStats) { t in
HStack(spacing: 8) {
Image(systemName: t.lastAnnounceSucceeded ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(t.lastAnnounceSucceeded ? .green : .orange)
VStack(alignment: .leading, spacing: 1) {
Text(t.host).font(.callout)
Text("\(t.resultText) · \(t.swarmText)").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
}
}
}
}
Divider()
actions(for: d)
if let last = downloads.lastAction {
Text(last).font(.caption2).foregroundStyle(.secondary)
}
if researchFor != nil || !researchResults.isEmpty || researchError != nil {
Divider()
researchPicker
}
}
@ViewBuilder private func actions(for d: TorrentDetail) -> some View {
let paused = d.status == 0
HStack(spacing: 10) {
Button { act(.reannounce, d) } label: { Label("Reannounce", systemImage: "antenna.radiowaves.left.and.right") }
.help("Re-ask the trackers for peers — the fix for a stuck / 0-peer transfer.")
Button { act(.verify, d) } label: { Label("Verify", systemImage: "checkmark.shield") }
.help("Recheck the downloaded pieces against their hashes.")
Button { act(paused ? .start : .stop, d) } label: {
Label(paused ? "Resume" : "Pause", systemImage: paused ? "play.fill" : "pause.fill")
}
Spacer()
if downloads.detailLoading { ProgressView().controlSize(.small) }
}
.buttonStyle(.bordered)
.disabled(downloads.detailLoading)
// v1 completion: dead/stalled/errored get an explicit "find better" path
// (uses health from the row if we can map back; falls back to detail state).
if let row = downloads.transfers.first(where: { $0.id == d.id }),
downloads.health(row).needsAttention || d.health().needsAttention {
Button {
Task { await researchAndSwap(d, row: row) }
} label: {
Label("Find better release…", systemImage: "magnifyingglass")
}
.buttonStyle(.bordered)
.disabled(downloads.detailLoading)
.help("Search for a better-seeded release of the same title and swap (keeps on-disk bytes).")
}
}
private func act(_ a: TorrentService.Action, _ d: TorrentDetail) {
Task { await downloads.act(a, on: d.id) }
}
private func stat(_ value: String, _ label: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(value).font(.callout.monospacedDigit())
Text(label).font(.caption2).foregroundStyle(.secondary)
}
}
// MARK: - v1 re-search swap (dead torrent "Find better release")
@State private var researchFor: TorrentRow?
@State private var researchResults: [TorrentResult] = []
@State private var researching = false
@State private var researchError: String?
private func researchAndSwap(_ d: TorrentDetail, row: TorrentRow) {
researchFor = row
researchResults = []
researchError = nil
researching = true
Task {
do {
let q = downloads.researchQuery(for: row)
let hits = try await downloads.searchForResearch(q, limit: 8)
researchResults = hits.filter { $0.addable && $0.filename != row.name }
if researchResults.isEmpty { researchError = "No better releases found for “\(q)”." }
} catch {
researchError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
researching = false
}
}
private func pickReplacement(_ hit: TorrentResult) {
guard let old = researchFor else { return }
let toSwap = hit
researchFor = nil; researchResults = []; researchError = nil
Task { await downloads.replace(old, with: toSwap) }
}
private var researchPicker: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Better releases for \(researchFor?.name ?? "")").font(.headline).lineLimit(1)
Spacer()
Button { researchFor = nil; researchResults = []; researchError = nil } label: {
Image(systemName: "xmark.circle.fill")
}.buttonStyle(.borderless).foregroundStyle(.secondary)
.help("Cancel research / close replacement picker")
}
if researching {
HStack { ProgressView().controlSize(.small); Text("Searching replacements…").foregroundStyle(.secondary) }
} else if let err = researchError {
CopyableText(text: err, systemImage: "exclamationmark.triangle")
} else if !researchResults.isEmpty {
ForEach(researchResults) { hit in
HStack {
VStack(alignment: .leading) {
Text(hit.filename).font(.callout).lineLimit(2)
HStack(spacing: 6) {
Label("\(hit.seeders)", systemImage: "arrow.up.circle.fill").foregroundStyle(hit.seeders >= 20 ? .green : .secondary)
Text(hit.size).foregroundStyle(.secondary)
Text(hit.source).foregroundStyle(.tertiary)
}.font(.caption)
}
Spacer()
Button("Swap to this") { pickReplacement(hit) }
.buttonStyle(.bordered).controlSize(.small)
}
.padding(6).background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
Text("Swap removes the old torrent entry but keeps its files on disk; the new torrent will verify/reuse them.").font(.caption2).foregroundStyle(.secondary)
}
}
}
}