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