|
|
@@ -0,0 +1,222 @@
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+/// Shows active and completed Soulseek downloads from slskd.
|
|
|
+struct DownloadsView: View {
|
|
|
+ @EnvironmentObject private var theme: AppTheme
|
|
|
+ @State private var transferGroups: [SlskdTransferGroup] = []
|
|
|
+ @State private var isLoading = false
|
|
|
+ @State private var error: String?
|
|
|
+ @State private var pollTask: Task<Void, Never>?
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack(spacing: 0) {
|
|
|
+ // Header
|
|
|
+ HStack {
|
|
|
+ Text("Downloads")
|
|
|
+ .font(.title2.bold())
|
|
|
+ .foregroundStyle(theme.primaryText)
|
|
|
+ Spacer()
|
|
|
+ Button {
|
|
|
+ Task { await refresh() }
|
|
|
+ } label: {
|
|
|
+ Image(systemName: "arrow.clockwise")
|
|
|
+ .font(.system(size: 13))
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .disabled(isLoading)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 20)
|
|
|
+ .padding(.vertical, 12)
|
|
|
+
|
|
|
+ Divider()
|
|
|
+
|
|
|
+ if isLoading && transferGroups.isEmpty {
|
|
|
+ Spacer()
|
|
|
+ ProgressView("Loading downloads...")
|
|
|
+ .foregroundStyle(theme.secondaryText)
|
|
|
+ Spacer()
|
|
|
+ } else if let error {
|
|
|
+ Spacer()
|
|
|
+ VStack(spacing: 8) {
|
|
|
+ Image(systemName: "exclamationmark.triangle")
|
|
|
+ .font(.title2)
|
|
|
+ .foregroundStyle(.orange)
|
|
|
+ Text(error)
|
|
|
+ .font(.callout)
|
|
|
+ .foregroundStyle(theme.secondaryText)
|
|
|
+ }
|
|
|
+ Spacer()
|
|
|
+ } else if allTransfers.isEmpty {
|
|
|
+ Spacer()
|
|
|
+ VStack(spacing: 8) {
|
|
|
+ Image(systemName: "arrow.down.circle")
|
|
|
+ .font(.system(size: 32))
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
+ Text("No active downloads")
|
|
|
+ .font(.callout)
|
|
|
+ .foregroundStyle(theme.secondaryText)
|
|
|
+ Text("Search for music and download from Soulseek sources.")
|
|
|
+ .font(.caption)
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
+ }
|
|
|
+ Spacer()
|
|
|
+ } else {
|
|
|
+ downloadsList
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ .onAppear { startPolling() }
|
|
|
+ .onDisappear { stopPolling() }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Downloads List
|
|
|
+
|
|
|
+ private var downloadsList: some View {
|
|
|
+ List {
|
|
|
+ ForEach(transferGroups, id: \.username) { group in
|
|
|
+ Section {
|
|
|
+ ForEach(transfersFor(group), id: \.filename) { transfer in
|
|
|
+ TransferRow(transfer: transfer)
|
|
|
+ }
|
|
|
+ } header: {
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ Image(systemName: "person.fill")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ Text(group.username)
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
+ Spacer()
|
|
|
+ Text(groupSummary(group))
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .listStyle(.inset)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Helpers
|
|
|
+
|
|
|
+ private var allTransfers: [SlskdTransfer] {
|
|
|
+ transferGroups.flatMap { group in
|
|
|
+ group.directories?.flatMap { $0.files ?? [] } ?? []
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func transfersFor(_ group: SlskdTransferGroup) -> [SlskdTransfer] {
|
|
|
+ group.directories?.flatMap { $0.files ?? [] } ?? []
|
|
|
+ }
|
|
|
+
|
|
|
+ private func groupSummary(_ group: SlskdTransferGroup) -> String {
|
|
|
+ let transfers = transfersFor(group)
|
|
|
+ let completed = transfers.filter(\.isComplete).count
|
|
|
+ let failed = transfers.filter(\.isFailed).count
|
|
|
+ let total = transfers.count
|
|
|
+ if failed > 0 {
|
|
|
+ return "\(completed)/\(total) done, \(failed) failed"
|
|
|
+ }
|
|
|
+ return "\(completed)/\(total) done"
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Polling
|
|
|
+
|
|
|
+ private func startPolling() {
|
|
|
+ pollTask = Task {
|
|
|
+ while !Task.isCancelled {
|
|
|
+ await refresh()
|
|
|
+ try? await Task.sleep(for: .seconds(3))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func stopPolling() {
|
|
|
+ pollTask?.cancel()
|
|
|
+ pollTask = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func refresh() async {
|
|
|
+ isLoading = true
|
|
|
+ do {
|
|
|
+ transferGroups = try await SlskdAPIClient.shared.getDownloads()
|
|
|
+ error = nil
|
|
|
+ } catch {
|
|
|
+ self.error = error.localizedDescription
|
|
|
+ }
|
|
|
+ isLoading = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Transfer Row
|
|
|
+
|
|
|
+private struct TransferRow: View {
|
|
|
+ let transfer: SlskdTransfer
|
|
|
+ @EnvironmentObject private var theme: AppTheme
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
+ // Filename
|
|
|
+ Text(displayName)
|
|
|
+ .font(.system(size: 12))
|
|
|
+ .foregroundStyle(theme.primaryText)
|
|
|
+ .lineLimit(1)
|
|
|
+
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ // State indicator
|
|
|
+ stateView
|
|
|
+
|
|
|
+ // Progress bar (only when downloading)
|
|
|
+ if isInProgress {
|
|
|
+ ProgressView(value: transfer.percentComplete / 100)
|
|
|
+ .progressViewStyle(.linear)
|
|
|
+ .frame(maxWidth: 200)
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ // Size
|
|
|
+ Text(ByteCountFormatter.string(fromByteCount: transfer.size, countStyle: .file))
|
|
|
+ .font(.system(size: 10, design: .monospaced))
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
+
|
|
|
+ // Speed
|
|
|
+ if let speed = transfer.averageSpeed, speed > 0, isInProgress {
|
|
|
+ Text(ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .file) + "/s")
|
|
|
+ .font(.system(size: 10, design: .monospaced))
|
|
|
+ .foregroundStyle(theme.tertiaryText)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.vertical, 2)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var displayName: String {
|
|
|
+ let normalized = transfer.filename.replacingOccurrences(of: "\\", with: "/")
|
|
|
+ return normalized.split(separator: "/").last.map(String.init) ?? transfer.filename
|
|
|
+ }
|
|
|
+
|
|
|
+ private var isInProgress: Bool {
|
|
|
+ !transfer.isComplete && !transfer.isFailed &&
|
|
|
+ (transfer.state.contains("InProgress") || transfer.state.contains("Queued"))
|
|
|
+ }
|
|
|
+
|
|
|
+ @ViewBuilder
|
|
|
+ private var stateView: some View {
|
|
|
+ if transfer.isComplete {
|
|
|
+ Image(systemName: "checkmark.circle.fill")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(.green)
|
|
|
+ } else if transfer.isFailed {
|
|
|
+ Image(systemName: "xmark.circle.fill")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(.red)
|
|
|
+ } else if transfer.state.contains("Queued") {
|
|
|
+ Image(systemName: "clock")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(.orange)
|
|
|
+ } else {
|
|
|
+ Image(systemName: "arrow.down.circle")
|
|
|
+ .font(.system(size: 10))
|
|
|
+ .foregroundStyle(theme.accent)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|