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? 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) } } }