tv-anarchy/Sources/TVAnarchy/DownloadsView.swift

248 lines
10 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)
Divider()
Button("Remove (keep data)", action: onRemoveKeep)
Button("Remove and delete data", role: .destructive, action: onRemoveData)
} 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)
}
}
@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)
}
}
@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)
}
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)
}
}
}