Browse Source

feat: add Downloads tab to sidebar for Soulseek transfers (chunk 5)

New DownloadsView shows active and completed slskd downloads grouped
by username with per-file progress bars, speed, state icons. Polls
every 3 seconds while visible. Sidebar shows Downloads item when
slskd is configured.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
aldiss 2 months ago
parent
commit
dc28168d4c

+ 4 - 0
MixBoard.xcodeproj/project.pbxproj

@@ -95,6 +95,7 @@
 		EE13D90C3C2ACF1348391C69 /* KeyDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0457B660537DC8CAD1B6120 /* KeyDetector.swift */; };
 		F0FF4D62FCE23A447DDE628F /* PlaylistUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C2D7260E0D82FD7D0BDA28 /* PlaylistUploadButton.swift */; };
 		F2E4BE62D73171D8E7D63006 /* CueSheetExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8A672BB52C77A8E83F3FFF /* CueSheetExporter.swift */; };
+		F4E2BD8E6DA70E2325277FEF /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAE1EDB557716061DEC42F0 /* DownloadsView.swift */; };
 		F7058DDE85BB601CBB7C9BD9 /* GlobalSearchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */; };
 /* End PBXBuildFile section */
 
@@ -138,6 +139,7 @@
 		2422CD2089E7C1331772CB63 /* MixBoard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MixBoard-Bridging-Header.h"; sourceTree = "<group>"; };
 		24ADE9A538A9797BE2D7862B /* LyricsParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsParser.swift; sourceTree = "<group>"; };
 		261573F9B9AABB23402AB3F2 /* ExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSheet.swift; sourceTree = "<group>"; };
+		2DAE1EDB557716061DEC42F0 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
 		3051FEE675462F2B77A356FC /* SyncImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncImporter.swift; sourceTree = "<group>"; };
 		33CBC0258B1C5E76582465F5 /* PlaylistFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFolder.swift; sourceTree = "<group>"; };
 		350E8D2B44F2BBFCD0364992 /* SlskdModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlskdModels.swift; sourceTree = "<group>"; };
@@ -387,6 +389,7 @@
 				962F30B9B736FF54E9E787D3 /* CloudBrowserView.swift */,
 				39DB5455D6BE460BC4F73953 /* ContentView.swift */,
 				4E30AA6107E4CCFDBA53EF0F /* DownloadIndicator.swift */,
+				2DAE1EDB557716061DEC42F0 /* DownloadsView.swift */,
 				261573F9B9AABB23402AB3F2 /* ExportSheet.swift */,
 				D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */,
 				1A61463B001623599676BEB7 /* GroupTemplateEditorSheet.swift */,
@@ -568,6 +571,7 @@
 				CF9C4D6F45A3CA4228A8CBEA /* DownloadIndicator.swift in Sources */,
 				ED3B403C28CF291E3483823E /* DownloadManager.swift in Sources */,
 				97DC2F7815AE935E67FCABB3 /* DownloadService.swift in Sources */,
+				F4E2BD8E6DA70E2325277FEF /* DownloadsView.swift in Sources */,
 				AFB70F19181547ABB1AFEE0A /* EDLExporter.swift in Sources */,
 				DD7452BB415E285D2D39A667 /* ExportSheet.swift in Sources */,
 				9EAB929A4063EF9BCBCC1E05 /* FileNameTemplate.swift in Sources */,

+ 3 - 0
Sources/Models/SidebarSection.swift

@@ -12,6 +12,9 @@ enum SidebarSection: Hashable {
     /// Playback queue — shows QueueView in center.
     case queue
 
+    /// Soulseek downloads — shows DownloadsView in center.
+    case downloads
+
     /// A user playlist — shows PlaylistView in center.
     case playlist(Playlist)
 }

+ 2 - 0
Sources/Views/ContentView.swift

@@ -52,6 +52,8 @@ struct ContentView: View {
                                 .id(dest)
                         case .queue:
                             QueueView()
+                        case .downloads:
+                            DownloadsView()
                         case .playlist(let playlist):
                             PlaylistView(playlist: playlist)
                         case nil:

+ 222 - 0
Sources/Views/DownloadsView.swift

@@ -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)
+        }
+    }
+}

+ 5 - 0
Sources/Views/SidebarView.swift

@@ -38,6 +38,11 @@ struct SidebarView: View {
                         .tag(SidebarSection.queue)
                         .accessibilityIdentifier("queueButton")
                 }
+
+                if SlskdAPIClient.shared.isConfigured {
+                    Label("Downloads", systemImage: "arrow.down.circle")
+                        .tag(SidebarSection.downloads)
+                }
             }
 
             // ── Playlists ────────────────────────────