248 lines
10 KiB
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)
|
|
}
|
|
}
|
|
}
|