Procházet zdrojové kódy

feat: consolidate Soulseek search with richer source details (chunk 1)

Replace auto-pick SoulseekOrchestrator callsite with navigation to
UnifiedSearchResultsView so users choose their source. Source rows now
show total size, bitrate (MP3 320k), queue length, and an expandable
file list. ScoreBadge moved from DJComponents to search results.
guessArtist() now parses "Artist - Album" format.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
aldiss před 2 měsíci
rodič
revize
32eb4b1995

+ 330 - 0
Sources/Services/SoulseekOrchestrator.swift

@@ -0,0 +1,330 @@
+import Foundation
+
+// MARK: - Pipeline State
+
+/// Represents the current state of the Soulseek acquisition pipeline.
+/// A-11: All associated values (String, Double) are Equatable — compiler auto-synthesizes conformance.
+enum SoulseekPipelineState: Equatable {
+    case idle
+    case searching(query: String)
+    case evaluating
+    case downloading(progress: Double)
+    case waitingForImport
+    case complete(albumName: String)
+    case failed(message: String)
+
+    var isActive: Bool {
+        switch self {
+        case .idle, .complete, .failed: false
+        default: true
+        }
+    }
+
+    var statusText: String {
+        switch self {
+        case .idle: "Ready"
+        case .searching(let q): "Searching Soulseek for \"\(q)\"..."
+        case .evaluating: "Evaluating sources..."
+        case .downloading(let p): "Downloading... \(Int(p * 100))%"
+        case .waitingForImport: "Waiting for ChadMusic import..."
+        case .complete(let name): "\(name) is ready"
+        case .failed(let msg): "Failed: \(msg)"
+        }
+    }
+}
+
+// MARK: - Orchestrator
+
+/// End-to-end pipeline: search slskd -> pick best source -> download -> trigger ChadMusic import.
+/// Observe `state` from the UI to show progress. Call `acquireAlbum` to start.
+@MainActor
+@Observable
+@available(*, deprecated, message: "Use UnifiedSearchCoordinator instead. SoulseekOrchestrator auto-picks sources without user choice.")
+final class SoulseekOrchestrator {
+    static let shared = SoulseekOrchestrator()
+
+    // MARK: - Public State
+
+    private(set) var state: SoulseekPipelineState = .idle
+
+    /// The album name that was successfully imported (set on .complete).
+    private(set) var importedAlbum: String?
+
+    /// The best response chosen during evaluation (for debugging/display).
+    private(set) var chosenSource: SlskdSearchResponse?
+
+    // MARK: - Private
+
+    private var activeTask: Task<Void, Never>?
+    private var activeSearchId: String?
+    /// H-1: Generation counter to prevent cancelled tasks from clobbering new pipeline state.
+    private var pipelineGeneration: UInt64 = 0
+
+    /// C-2: Set of filenames we enqueued for download in this pipeline run.
+    /// Used to filter out stale transfers from the same peer.
+    private var downloadEnqueuedFiles: Set<String> = []
+
+    private let slskd = SlskdAPIClient.shared
+    private let chadMusic = ChadMusicAPIClient.shared
+
+    // Pipeline tuning constants
+    private let searchPollInterval: TimeInterval = 2
+    private let searchTimeout: TimeInterval = 30
+    private let downloadPollInterval: TimeInterval = 2
+    private let downloadTimeout: TimeInterval = 600  // 10 minutes
+    private let importPollInterval: TimeInterval = 10
+    private let importTimeout: TimeInterval = 300  // 5 minutes
+    // A-3: User-configurable quality threshold (persisted in UserDefaults).
+    // Default 80: FLAC/lossless + completeness. Users wanting 128kbps MP3 can lower it.
+    private var qualityThreshold: Int {
+        let stored = UserDefaults.standard.integer(forKey: "slskd.qualityThreshold")
+        return stored > 0 ? stored : 80
+    }
+
+    // MARK: - Public API
+
+    /// Start the full acquisition pipeline. Cancels any active pipeline first.
+    func acquireAlbum(artist: String, albumName: String, expectedTrackCount: Int? = nil) {
+        cancel()
+
+        let query = "\(artist) \(albumName)"
+        state = .searching(query: query)
+        importedAlbum = nil
+        chosenSource = nil
+
+        // H-1: Capture the generation so the catch block can check staleness.
+        pipelineGeneration &+= 1
+        let myGeneration = pipelineGeneration
+
+        activeTask = Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await self.runPipeline(
+                    query: query,
+                    artist: artist,
+                    albumName: albumName,
+                    expectedTrackCount: expectedTrackCount
+                )
+            } catch is CancellationError {
+                // H-1: Only reset to .idle if no newer pipeline has started.
+                if self.pipelineGeneration == myGeneration {
+                    self.state = .idle
+                }
+            } catch let error as SlskdError {
+                if self.pipelineGeneration == myGeneration {
+                    self.state = .failed(message: error.errorDescription ?? "Unknown error")
+                }
+            } catch {
+                if self.pipelineGeneration == myGeneration {
+                    self.state = .failed(message: error.localizedDescription)
+                }
+            }
+        }
+    }
+
+    /// Cancel the active pipeline and clean up.
+    func cancel() {
+        activeTask?.cancel()
+        activeTask = nil
+
+        // Best-effort: delete active search on slskd
+        if let searchId = activeSearchId {
+            activeSearchId = nil
+            Task {
+                try? await slskd.deleteSearch(id: searchId)
+            }
+        }
+
+        if state.isActive {
+            state = .idle
+        }
+    }
+
+    /// Dismiss a completed/failed result (returns to idle).
+    func dismiss() {
+        guard !state.isActive else { return }
+        state = .idle
+        importedAlbum = nil
+        chosenSource = nil
+    }
+
+    // MARK: - Pipeline Steps
+
+    private func runPipeline(
+        query: String,
+        artist: String,
+        albumName: String,
+        expectedTrackCount: Int?
+    ) async throws {
+        // Step 1: Search
+        let searchId = try await slskd.startSearch(query: query)
+        activeSearchId = searchId
+
+        let responses = try await pollSearch(id: searchId)
+        activeSearchId = nil
+
+        guard !responses.isEmpty else {
+            throw SlskdError.noResults
+        }
+
+        // Step 2: Evaluate
+        try Task.checkCancellation()
+        state = .evaluating
+
+        let bestResponse = pickBestSource(
+            responses: responses,
+            expectedTrackCount: expectedTrackCount
+        )
+
+        guard let source = bestResponse else {
+            throw SlskdError.noQualityMatch
+        }
+
+        chosenSource = source
+
+        // Step 3: Download
+        try Task.checkCancellation()
+        state = .downloading(progress: 0)
+
+        let audioFiles = source.files.filter(\.isAudioFile)
+        // C-2: Track which files we enqueued so pollDownloads can ignore stale transfers.
+        downloadEnqueuedFiles = Set(audioFiles.map(\.filename))
+        try await slskd.enqueueDownloads(username: source.username, files: audioFiles)
+        try await pollDownloads(
+            username: source.username,
+            expectedCount: audioFiles.count,
+            enqueuedFiles: downloadEnqueuedFiles
+        )
+
+        // Step 4: Trigger ChadMusic import
+        try Task.checkCancellation()
+        state = .waitingForImport
+
+        try await triggerImportAndWait(artist: artist, albumName: albumName)
+
+        // Step 5: Complete
+        importedAlbum = albumName
+        state = .complete(albumName: albumName)
+
+        // Clean up the search
+        try? await slskd.deleteSearch(id: searchId)
+    }
+
+    // MARK: - Search Polling
+
+    private func pollSearch(id: String) async throws -> [SlskdSearchResponse] {
+        let deadline = Date().addingTimeInterval(searchTimeout)
+
+        while Date() < deadline {
+            try Task.checkCancellation()
+
+            let search = try await slskd.getSearch(id: id)
+            if search.isComplete {
+                return search.responses ?? []
+            }
+
+            try await Task.sleep(for: .seconds(searchPollInterval))
+        }
+
+        // Timed out — grab whatever we have
+        let finalSearch = try await slskd.getSearch(id: id)
+        if let responses = finalSearch.responses, !responses.isEmpty {
+            return responses
+        }
+        throw SlskdError.searchTimeout
+    }
+
+    // MARK: - Source Selection
+
+    private func pickBestSource(
+        responses: [SlskdSearchResponse],
+        expectedTrackCount: Int?
+    ) -> SlskdSearchResponse? {
+        let scored = responses
+            .map { (response: $0, score: $0.qualityScore(expectedTrackCount: expectedTrackCount)) }
+            .filter { $0.score >= qualityThreshold }
+            .sorted { $0.score > $1.score }
+
+        return scored.first?.response
+    }
+
+    // MARK: - Download Polling
+
+    private func pollDownloads(username: String, expectedCount: Int, enqueuedFiles: Set<String>) async throws {
+        let deadline = Date().addingTimeInterval(downloadTimeout)
+
+        while Date() < deadline {
+            try Task.checkCancellation()
+
+            let groups = try await slskd.getDownloads()
+
+            // Find our user's transfer group
+            if let group = groups.first(where: { $0.username == username }) {
+                let allFiles = group.directories?.flatMap { $0.files ?? [] } ?? []
+
+                // C-2: Only count transfers for files we actually enqueued this run.
+                // This filters out stale completed/failed transfers from previous downloads.
+                let ourFiles = allFiles.filter { enqueuedFiles.contains($0.filename) }
+
+                let completed = ourFiles.filter(\.isComplete).count
+                let failed = ourFiles.filter(\.isFailed).count
+                let total = max(expectedCount, ourFiles.count)
+
+                // Update progress
+                let progress = total > 0 ? Double(completed + failed) / Double(total) : 0
+                state = .downloading(progress: min(progress, 1.0))
+
+                // Check if done
+                if completed + failed >= expectedCount {
+                    // H-4: Use ceiling division so 1/2 failures is caught.
+                    // For expectedCount=2: threshold=1, so 1 failure throws (1 >= 1).
+                    let failThreshold = (expectedCount + 1) / 2
+                    if failed >= failThreshold {
+                        throw SlskdError.downloadFailed(
+                            "\(failed)/\(expectedCount) files failed"
+                        )
+                    }
+                    return  // Done downloading
+                }
+            }
+
+            try await Task.sleep(for: .seconds(downloadPollInterval))
+        }
+
+        throw SlskdError.downloadFailed("Download timed out after 10 minutes")
+    }
+
+    // MARK: - ChadMusic Import
+
+    private func triggerImportAndWait(artist: String, albumName: String) async throws {
+        // Trigger rescan
+        try await chadMusic.triggerRescan()
+
+        // Poll until the album appears
+        let deadline = Date().addingTimeInterval(importTimeout)
+        let searchLower = albumName.lowercased()
+
+        while Date() < deadline {
+            try Task.checkCancellation()
+
+            try await Task.sleep(for: .seconds(importPollInterval))
+
+            // Check if album now exists in ChadMusic
+            do {
+                let albums = try await chadMusic.fetchAlbums(filteredBy: "artist", value: artist)
+                // A-6: Use exact match instead of substring (.contains) to avoid
+                // false positives like "I" matching every album title.
+                if albums.contains(where: { $0.title.lowercased() == searchLower }) {
+                    return  // Album found
+                }
+            } catch {
+                // ChadMusic might be rescanning — ignore transient errors
+                continue
+            }
+        }
+
+        // C-3: Import timed out — throw so pipeline shows .failed, not .complete.
+        // The files ARE downloaded on the server; the user should rescan manually.
+        throw SlskdError.importTimeout
+    }
+}

+ 359 - 0
Sources/Services/UnifiedSearchCoordinator.swift

@@ -0,0 +1,359 @@
+import Foundation
+
+// MARK: - Search Phase
+
+enum UnifiedSearchPhase: Equatable {
+    case idle
+    case searchingCloud
+    case searchingSoulseek
+    case done
+    case error(String)
+}
+
+// MARK: - Download Phase
+
+enum SourceDownloadPhase: Equatable {
+    case idle
+    case downloading(progress: Double)
+    case importing
+    case complete(albumName: String)
+    case failed(String)
+
+    var isActive: Bool {
+        switch self {
+        case .idle, .complete, .failed: false
+        default: true
+        }
+    }
+
+    var statusText: String {
+        switch self {
+        case .idle: "Ready"
+        case .downloading(let p): "Downloading... \(Int(p * 100))%"
+        case .importing: "Importing to ChadMusic..."
+        case .complete(let name): "\(name) ready"
+        case .failed(let msg): "Failed: \(msg)"
+        }
+    }
+}
+
+// MARK: - Scored Source
+
+struct ScoredSoulseekSource: Identifiable {
+    let response: SlskdSearchResponse
+    let score: Int
+
+    var id: String { response.id }
+
+    /// Best audio format in this source.
+    var bestFormat: String {
+        let audioFiles = response.files.filter(\.isAudioFile)
+        let extensions = audioFiles.compactMap(\.fileExtension)
+
+        // Priority order
+        for fmt in ["flac", "wav", "aiff", "aif", "ape", "wv", "m4a", "ogg", "aac", "mp3"] {
+            if extensions.contains(fmt) { return fmt.uppercased() }
+        }
+        return extensions.first?.uppercased() ?? "?"
+    }
+
+    /// Number of audio files.
+    var audioFileCount: Int {
+        response.files.filter(\.isAudioFile).count
+    }
+
+    /// Audio files in this source.
+    var audioFiles: [SlskdFile] {
+        response.files.filter(\.isAudioFile)
+    }
+
+    /// Total size of audio files in bytes.
+    var totalSize: Int64 {
+        audioFiles.reduce(0) { $0 + $1.size }
+    }
+
+    /// Human-readable total size (e.g., "847 MB").
+    var formattedTotalSize: String {
+        ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
+    }
+
+    /// Format + bitrate display (e.g., "FLAC", "MP3 320k").
+    var formatDisplay: String {
+        let fmt = bestFormat
+        if fmt == "MP3" {
+            let maxBitrate = audioFiles.compactMap(\.bitRate).max()
+            if let br = maxBitrate, br > 0 {
+                return "MP3 \(br)k"
+            }
+        }
+        return fmt
+    }
+
+    /// Score color: green (excellent), yellow (acceptable), red (poor).
+    var scoreGrade: ScoreGrade {
+        if score >= 120 { return .excellent }
+        if score >= 80 { return .good }
+        return .poor
+    }
+
+    enum ScoreGrade {
+        case excellent, good, poor
+    }
+}
+
+// MARK: - Coordinator
+
+/// Orchestrates cloud-first, Soulseek-fallback search.
+/// NOT a singleton — create one per search session.
+@MainActor
+@Observable
+final class UnifiedSearchCoordinator {
+
+    // MARK: - Published State
+
+    private(set) var phase: UnifiedSearchPhase = .idle
+    private(set) var cloudResults: [ChadAlbum] = []
+    private(set) var soulseekSources: [ScoredSoulseekSource] = []
+    private(set) var downloadPhase: SourceDownloadPhase = .idle
+
+    // MARK: - Private
+
+    private let chadMusic = ChadMusicAPIClient.shared
+    private let slskd = SlskdAPIClient.shared
+    private nonisolated(unsafe) var searchTask: Task<Void, Never>?
+    private nonisolated(unsafe) var downloadTask: Task<Void, Never>?
+    private nonisolated(unsafe) var activeSearchId: String?
+
+    private let searchPollInterval: TimeInterval = 2
+    private let searchTimeout: TimeInterval = 30
+    private let downloadPollInterval: TimeInterval = 3
+    private let downloadTimeout: TimeInterval = 600
+    private let importTimeout: TimeInterval = 300
+
+    /// Quality threshold for display. Sources below this are shown grayed out.
+    private var qualityThreshold: Int {
+        let stored = UserDefaults.standard.integer(forKey: "slskd.qualityThreshold")
+        return stored > 0 ? stored : 80
+    }
+
+    deinit {
+        searchTask?.cancel()
+        downloadTask?.cancel()
+        // Best-effort cleanup
+        if let searchId = activeSearchId {
+            let client = SlskdAPIClient.shared
+            Task { try? await client.deleteSearch(id: searchId) }
+        }
+    }
+
+    // MARK: - Search
+
+    /// Cloud-first search. If ChadMusic has no results and Soulseek is configured,
+    /// automatically searches Soulseek and returns scored sources for the user to pick.
+    func search(query: String) {
+        cancel()
+
+        searchTask = Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await self.runSearch(query: query)
+            } catch is CancellationError {
+                // Cancelled — leave state as-is
+            } catch {
+                self.phase = .error(error.localizedDescription)
+            }
+        }
+    }
+
+    /// Cancel any active search or download.
+    func cancel() {
+        searchTask?.cancel()
+        searchTask = nil
+        downloadTask?.cancel()
+        downloadTask = nil
+
+        if let searchId = activeSearchId {
+            activeSearchId = nil
+            Task { try? await slskd.deleteSearch(id: searchId) }
+        }
+
+        cloudResults = []
+        soulseekSources = []
+        phase = .idle
+        downloadPhase = .idle
+    }
+
+    /// Download a specific Soulseek source picked by the user.
+    func downloadSource(
+        _ source: SlskdSearchResponse,
+        artist: String,
+        albumName: String
+    ) {
+        downloadTask?.cancel()
+        downloadPhase = .downloading(progress: 0)
+
+        downloadTask = Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await self.runDownloadAndImport(
+                    source: source,
+                    artist: artist,
+                    albumName: albumName
+                )
+            } catch is CancellationError {
+                self.downloadPhase = .idle
+            } catch {
+                self.downloadPhase = .failed(error.localizedDescription)
+            }
+        }
+    }
+
+    /// Dismiss a completed/failed download state.
+    func dismissDownload() {
+        guard !downloadPhase.isActive else { return }
+        downloadPhase = .idle
+    }
+
+    // MARK: - Private: Search Pipeline
+
+    private func runSearch(query: String) async throws {
+        let lowerQuery = query.lowercased()
+
+        // Step 1: Search ChadMusic (client-side filter)
+        phase = .searchingCloud
+
+        let allAlbums = try await chadMusic.fetchAlbums()
+        try Task.checkCancellation()
+
+        cloudResults = allAlbums.filter { album in
+            album.title.lowercased().contains(lowerQuery) ||
+            (album.artist?.lowercased().contains(lowerQuery) ?? false)
+        }
+
+        // If we found cloud results, we're done
+        if !cloudResults.isEmpty {
+            phase = .done
+            return
+        }
+
+        // Step 2: No cloud results — try Soulseek if configured
+        guard slskd.isConfigured else {
+            phase = .done
+            return
+        }
+
+        try Task.checkCancellation()
+        phase = .searchingSoulseek
+
+        let searchId = try await slskd.startSearch(query: query)
+        activeSearchId = searchId
+
+        let responses = try await pollSearch(id: searchId)
+        activeSearchId = nil
+
+        // Score and sort responses
+        soulseekSources = responses
+            .map { ScoredSoulseekSource(response: $0, score: $0.qualityScore(expectedTrackCount: nil)) }
+            .filter { $0.score > 0 }
+            .sorted { $0.score > $1.score }
+
+        try Task.checkCancellation()
+        phase = .done
+    }
+
+    private func pollSearch(id: String) async throws -> [SlskdSearchResponse] {
+        let deadline = Date().addingTimeInterval(searchTimeout)
+
+        while Date() < deadline {
+            try Task.checkCancellation()
+
+            let search = try await slskd.getSearch(id: id)
+            if search.isComplete {
+                return search.responses ?? []
+            }
+
+            try await Task.sleep(for: .seconds(searchPollInterval))
+        }
+
+        // Grab whatever we have
+        let finalSearch = try await slskd.getSearch(id: id)
+        return finalSearch.responses ?? []
+    }
+
+    // MARK: - Private: Download + Import Pipeline
+
+    private func runDownloadAndImport(
+        source: SlskdSearchResponse,
+        artist: String,
+        albumName: String
+    ) async throws {
+        let audioFiles = source.files.filter(\.isAudioFile)
+        guard !audioFiles.isEmpty else {
+            throw SlskdError.noResults
+        }
+
+        // Step 1: Enqueue downloads
+        downloadPhase = .downloading(progress: 0)
+        let enqueuedFilenames = Set(audioFiles.map(\.filename))
+        try await slskd.enqueueDownloads(username: source.username, files: audioFiles)
+
+        // Step 2: Poll downloads
+        let deadline = Date().addingTimeInterval(downloadTimeout)
+        let expectedCount = audioFiles.count
+
+        while Date() < deadline {
+            try Task.checkCancellation()
+
+            let groups = try await slskd.getDownloads()
+            if let group = groups.first(where: { $0.username == source.username }) {
+                let allFiles = group.directories?.flatMap { $0.files ?? [] } ?? []
+                let ourFiles = allFiles.filter { enqueuedFilenames.contains($0.filename) }
+
+                let completed = ourFiles.filter(\.isComplete).count
+                let failed = ourFiles.filter(\.isFailed).count
+                let total = max(expectedCount, ourFiles.count)
+
+                let progress = total > 0 ? Double(completed + failed) / Double(total) : 0
+                downloadPhase = .downloading(progress: min(progress, 1.0))
+
+                if completed + failed >= expectedCount {
+                    let failThreshold = (expectedCount + 1) / 2
+                    if failed >= failThreshold {
+                        throw SlskdError.downloadFailed("\(failed)/\(expectedCount) files failed")
+                    }
+                    break
+                }
+            }
+
+            try await Task.sleep(for: .seconds(downloadPollInterval))
+        }
+
+        // Step 3: Trigger ChadMusic import
+        try Task.checkCancellation()
+        downloadPhase = .importing
+
+        try await chadMusic.triggerRescan()
+
+        // Poll for album appearance
+        let importDeadline = Date().addingTimeInterval(importTimeout)
+        let searchLower = albumName.lowercased()
+
+        while Date() < importDeadline {
+            try Task.checkCancellation()
+            try await Task.sleep(for: .seconds(10))
+
+            do {
+                let albums = try await chadMusic.fetchAlbums(filteredBy: "artist", value: artist)
+                if albums.contains(where: { $0.title.lowercased() == searchLower }) {
+                    downloadPhase = .complete(albumName: albumName)
+                    return
+                }
+            } catch {
+                continue
+            }
+        }
+
+        // Files are downloaded but import timed out
+        throw SlskdError.importTimeout
+    }
+}

+ 1 - 4
Sources/Views/CloudBrowserView.swift

@@ -414,10 +414,7 @@ private struct FilteredAlbumsView: View {
 
                             if SlskdAPIClient.shared.isConfigured {
                                 Button {
-                                    SoulseekOrchestrator.shared.acquireAlbum(
-                                        artist: filter.value,
-                                        albumName: albumSearchText
-                                    )
+                                    navStack.append(.search(query: "\(filter.value) - \(albumSearchText)"))
                                 } label: {
                                     Label("Search Soulseek", systemImage: "magnifyingglass")
                                 }

+ 420 - 0
Sources/Views/DJ/DJComponents.swift

@@ -0,0 +1,420 @@
+import SwiftUI
+
+// MARK: - Rotary Knob
+
+/// A hardware-inspired rotary knob control. Drag vertically to adjust value.
+struct RotaryKnobView: View {
+    @Binding var value: Float  // 0...1
+    var label: String = ""
+    var size: CGFloat = 48
+    var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
+
+    @State private var isDragging = false
+    @State private var dragStartValue: Float = 0
+
+    // Knob rotates from -135deg to +135deg (270deg total range)
+    private var rotationAngle: Double {
+        Double(value) * 270 - 135
+    }
+
+    var body: some View {
+        VStack(spacing: 4) {
+            ZStack {
+                // Outer ring — brushed metal
+                Circle()
+                    .fill(
+                        AngularGradient(
+                            colors: [
+                                Color(white: 0.25), Color(white: 0.35),
+                                Color(white: 0.20), Color(white: 0.30),
+                                Color(white: 0.25),
+                            ],
+                            center: .center
+                        )
+                    )
+                    .frame(width: size, height: size)
+
+                // Value arc
+                Circle()
+                    .trim(from: 0, to: CGFloat(value) * 0.75)
+                    .rotation(.degrees(135))
+                    .stroke(
+                        accentColor,
+                        style: StrokeStyle(lineWidth: 3, lineCap: .round)
+                    )
+                    .frame(width: size + 6, height: size + 6)
+                    .shadow(color: accentColor.opacity(0.6), radius: 4)
+
+                // Inner circle — darker center
+                Circle()
+                    .fill(
+                        RadialGradient(
+                            colors: [Color(white: 0.18), Color(white: 0.12)],
+                            center: .center,
+                            startRadius: 0,
+                            endRadius: size * 0.35
+                        )
+                    )
+                    .frame(width: size * 0.7, height: size * 0.7)
+
+                // Position indicator line
+                Rectangle()
+                    .fill(accentColor)
+                    .frame(width: 2, height: size * 0.25)
+                    .offset(y: -size * 0.2)
+                    .rotationEffect(.degrees(rotationAngle))
+                    .shadow(color: accentColor.opacity(0.8), radius: 2)
+            }
+            .gesture(
+                DragGesture(minimumDistance: 1)
+                    .onChanged { gesture in
+                        if !isDragging {
+                            isDragging = true
+                            dragStartValue = value
+                        }
+                        // Vertical drag: up = increase, down = decrease
+                        let delta = Float(-gesture.translation.height / 150)
+                        value = max(0, min(1, dragStartValue + delta))
+                    }
+                    .onEnded { _ in isDragging = false }
+            )
+
+            if !label.isEmpty {
+                Text(label)
+                    .font(.system(size: 9, weight: .bold, design: .monospaced))
+                    .foregroundStyle(Color(white: 0.5))
+            }
+        }
+    }
+}
+
+// MARK: - VU Meter
+
+/// Vertical LED-strip level meter. Green → Yellow → Red.
+struct VUMeterView: View {
+    var level: Float  // 0...1
+    var segmentCount: Int = 12
+    var width: CGFloat = 8
+    var height: CGFloat = 60
+
+    var body: some View {
+        VStack(spacing: 1.5) {
+            ForEach((0..<segmentCount).reversed(), id: \.self) { index in
+                let threshold = Float(index) / Float(segmentCount)
+                let isLit = level > threshold
+                let color = segmentColor(index: index)
+
+                RoundedRectangle(cornerRadius: 1)
+                    .fill(isLit ? color : color.opacity(0.15))
+                    .frame(width: width, height: max(2, height / CGFloat(segmentCount) - 1.5))
+                    .shadow(color: isLit ? color.opacity(0.5) : .clear, radius: 2)
+            }
+        }
+    }
+
+    private func segmentColor(index: Int) -> Color {
+        let ratio = Float(index) / Float(segmentCount)
+        if ratio >= 0.83 { return Color(red: 1.0, green: 0.1, blue: 0.1) }      // Red
+        if ratio >= 0.66 { return Color(red: 1.0, green: 0.8, blue: 0.0) }      // Yellow
+        return Color(red: 0.0, green: 0.85, blue: 0.4)                            // Green
+    }
+}
+
+// MARK: - LED Display
+
+/// Seven-segment-style glowing text display for BPM, time, key.
+struct LEDDisplay: View {
+    let text: String
+    var fontSize: CGFloat = 16
+    var color: Color = Color(red: 0, green: 0.83, blue: 1.0)
+    var alignment: Alignment = .center
+
+    var body: some View {
+        Text(text)
+            .font(.system(size: fontSize, weight: .bold, design: .monospaced))
+            .foregroundStyle(color)
+            .shadow(color: color.opacity(0.7), radius: 4)
+            .shadow(color: color.opacity(0.3), radius: 8)
+            .frame(maxWidth: .infinity, alignment: alignment)
+            .padding(.horizontal, 6)
+            .padding(.vertical, 3)
+            .background(
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(Color(white: 0.05))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 4)
+                            .stroke(Color(white: 0.15), lineWidth: 0.5)
+                    )
+            )
+    }
+}
+
+// MARK: - DJ Transport Button
+
+/// Hardware-inspired raised button for transport controls.
+struct DJTransportButton: View {
+    let icon: String
+    var size: ButtonSize = .regular
+    var isActive: Bool = false
+    var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
+    let action: () -> Void
+
+    enum ButtonSize {
+        case small, regular, large
+
+        var dimension: CGFloat {
+            switch self {
+            case .small: 28
+            case .regular: 36
+            case .large: 52
+            }
+        }
+
+        var iconSize: CGFloat {
+            switch self {
+            case .small: 12
+            case .regular: 16
+            case .large: 24
+            }
+        }
+    }
+
+    @State private var isPressed = false
+
+    var body: some View {
+        Button(action: action) {
+            Image(systemName: icon)
+                .font(.system(size: size.iconSize, weight: .semibold))
+                .foregroundStyle(isActive ? accentColor : Color(white: 0.7))
+                .frame(width: size.dimension, height: size.dimension)
+                .background(
+                    ZStack {
+                        // Base
+                        RoundedRectangle(cornerRadius: size.dimension * 0.2)
+                            .fill(
+                                LinearGradient(
+                                    colors: isPressed
+                                        ? [Color(white: 0.12), Color(white: 0.16)]
+                                        : [Color(white: 0.22), Color(white: 0.14)],
+                                    startPoint: .top,
+                                    endPoint: .bottom
+                                )
+                            )
+
+                        // Border
+                        RoundedRectangle(cornerRadius: size.dimension * 0.2)
+                            .stroke(
+                                LinearGradient(
+                                    colors: [Color(white: 0.3), Color(white: 0.1)],
+                                    startPoint: .top,
+                                    endPoint: .bottom
+                                ),
+                                lineWidth: 1
+                            )
+                    }
+                )
+                .shadow(
+                    color: isActive ? accentColor.opacity(0.3) : .clear,
+                    radius: 4
+                )
+                .shadow(
+                    color: Color.black.opacity(isPressed ? 0 : 0.5),
+                    radius: isPressed ? 0 : 2,
+                    y: isPressed ? 0 : 2
+                )
+                .scaleEffect(isPressed ? 0.95 : 1.0)
+                .animation(.easeOut(duration: 0.1), value: isPressed)
+        }
+        .buttonStyle(.plain)
+        .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
+            isPressed = pressing
+        }, perform: {})
+    }
+}
+
+// MARK: - Fader
+
+/// Vertical fader for EQ bands. Drag to adjust.
+struct FaderView: View {
+    @Binding var value: Float  // -1...1 for EQ, or 0...1 for volume
+    var label: String = ""
+    var range: ClosedRange<Float> = -1...1
+    var height: CGFloat = 80
+    var accentColor: Color = Color(red: 0, green: 0.83, blue: 1.0)
+
+    @State private var isDragging = false
+    @State private var dragStartValue: Float = 0
+
+    private var normalizedValue: CGFloat {
+        CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound))
+    }
+
+    var body: some View {
+        VStack(spacing: 4) {
+            ZStack(alignment: .bottom) {
+                // Track groove
+                RoundedRectangle(cornerRadius: 2)
+                    .fill(Color(white: 0.08))
+                    .frame(width: 6, height: height)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 2)
+                            .stroke(Color(white: 0.2), lineWidth: 0.5)
+                    )
+
+                // Value fill
+                RoundedRectangle(cornerRadius: 2)
+                    .fill(accentColor.opacity(0.5))
+                    .frame(width: 6, height: height * normalizedValue)
+
+                // Fader cap
+                RoundedRectangle(cornerRadius: 3)
+                    .fill(
+                        LinearGradient(
+                            colors: [Color(white: 0.45), Color(white: 0.25)],
+                            startPoint: .top,
+                            endPoint: .bottom
+                        )
+                    )
+                    .frame(width: 20, height: 12)
+                    .shadow(color: .black.opacity(0.4), radius: 2, y: 1)
+                    .offset(y: -height * normalizedValue + 6)
+            }
+            .frame(width: 20, height: height)
+            .gesture(
+                DragGesture(minimumDistance: 1)
+                    .onChanged { gesture in
+                        if !isDragging {
+                            isDragging = true
+                            dragStartValue = value
+                        }
+                        let delta = Float(-gesture.translation.height / height)
+                            * (range.upperBound - range.lowerBound)
+                        value = max(range.lowerBound, min(range.upperBound, dragStartValue + delta))
+                    }
+                    .onEnded { _ in isDragging = false }
+            )
+
+            if !label.isEmpty {
+                Text(label)
+                    .font(.system(size: 8, weight: .bold, design: .monospaced))
+                    .foregroundStyle(Color(white: 0.5))
+            }
+        }
+    }
+}
+
+// MARK: - DJ Section Background
+
+/// Textured surface for grouping DJ controls.
+struct DJSectionBackground: View {
+    var cornerRadius: CGFloat = 8
+
+    var body: some View {
+        RoundedRectangle(cornerRadius: cornerRadius)
+            .fill(
+                LinearGradient(
+                    colors: [
+                        Color(red: 0.06, green: 0.06, blue: 0.08),
+                        Color(red: 0.04, green: 0.04, blue: 0.06),
+                    ],
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: cornerRadius)
+                    .stroke(
+                        LinearGradient(
+                            colors: [Color(white: 0.15), Color(white: 0.05)],
+                            startPoint: .top,
+                            endPoint: .bottom
+                        ),
+                        lineWidth: 1
+                    )
+            )
+            .shadow(color: .black.opacity(0.5), radius: 4, y: 2)
+    }
+}
+
+// MARK: - Vinyl Spin Animation
+
+/// Album art with vinyl grooves that spins during playback.
+struct VinylSpinView: View {
+    let trackTitle: String
+    let artworkView: AnyView?
+    var isPlaying: Bool
+    var size: CGFloat = 120
+
+    @State private var rotation: Double = 0
+
+    var body: some View {
+        ZStack {
+            // Vinyl disc
+            Circle()
+                .fill(
+                    RadialGradient(
+                        colors: [
+                            Color(white: 0.08),
+                            Color(white: 0.04),
+                            Color(white: 0.06),
+                            Color(white: 0.03),
+                            Color(white: 0.05),
+                        ],
+                        center: .center,
+                        startRadius: size * 0.2,
+                        endRadius: size * 0.5
+                    )
+                )
+                .frame(width: size, height: size)
+
+            // Grooves (concentric rings)
+            ForEach(0..<6, id: \.self) { ring in
+                let ringRadius = size * 0.22 + CGFloat(ring) * (size * 0.045)
+                Circle()
+                    .stroke(Color(white: 0.1), lineWidth: 0.5)
+                    .frame(width: ringRadius * 2, height: ringRadius * 2)
+            }
+
+            // Center label (album art or placeholder)
+            if let artwork = artworkView {
+                artwork
+                    .frame(width: size * 0.35, height: size * 0.35)
+                    .clipShape(Circle())
+            } else {
+                Circle()
+                    .fill(Color(white: 0.12))
+                    .frame(width: size * 0.35, height: size * 0.35)
+                    .overlay(
+                        Text(String(trackTitle.prefix(2)).uppercased())
+                            .font(.system(size: size * 0.08, weight: .bold, design: .monospaced))
+                            .foregroundStyle(Color(white: 0.4))
+                    )
+            }
+
+            // Spindle hole
+            Circle()
+                .fill(Color(white: 0.02))
+                .frame(width: size * 0.05, height: size * 0.05)
+        }
+        .rotationEffect(.degrees(rotation))
+        .onChange(of: isPlaying) { _, playing in
+            if playing {
+                withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
+                    rotation += 360
+                }
+            } else {
+                // Stop smoothly — remove repeating animation
+                withAnimation(.easeOut(duration: 0.5)) {
+                    // Keep current rotation (no reset)
+                }
+            }
+        }
+        .onAppear {
+            if isPlaying {
+                withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) {
+                    rotation = 360
+                }
+            }
+        }
+    }
+}

+ 415 - 0
Sources/Views/UnifiedSearchResultsView.swift

@@ -0,0 +1,415 @@
+import SwiftUI
+
+// MARK: - Unified Search Results
+
+/// Shows ChadMusic library results and Soulseek sources in a single view.
+/// Pushed onto CloudBrowserView's navStack when user submits a search query.
+struct UnifiedSearchResultsView: View {
+    let query: String
+    @Binding var navStack: [CloudNavDestination]
+
+    @State private var coordinator = UnifiedSearchCoordinator()
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Phase indicator
+            searchPhaseHeader
+
+            Divider()
+
+            // Results
+            Group {
+                switch coordinator.phase {
+                case .idle:
+                    EmptyView()
+
+                case .searchingCloud:
+                    searchingView("Searching your library...")
+
+                case .searchingSoulseek:
+                    searchingView("Not in library. Searching Soulseek...")
+
+                case .error(let msg):
+                    errorView(msg)
+
+                case .done:
+                    if coordinator.cloudResults.isEmpty && coordinator.soulseekSources.isEmpty {
+                        noResultsView
+                    } else {
+                        resultsList
+                    }
+                }
+            }
+
+            // Download progress bar
+            if coordinator.downloadPhase != .idle {
+                downloadStatusBar
+            }
+        }
+        .task {
+            coordinator.search(query: query)
+        }
+    }
+
+    // MARK: - Subviews
+
+    private var searchPhaseHeader: some View {
+        HStack(spacing: 8) {
+            Image(systemName: "magnifyingglass")
+                .foregroundStyle(theme.secondaryText)
+            Text("\"\(query)\"")
+                .font(.system(size: 13, weight: .medium))
+                .foregroundStyle(theme.primaryText)
+                .lineLimit(1)
+            Spacer()
+
+            if coordinator.phase == .searchingCloud || coordinator.phase == .searchingSoulseek {
+                ProgressView()
+                    .controlSize(.small)
+            }
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 8)
+        .background(theme.toolbarBackground.opacity(0.3))
+    }
+
+    private func searchingView(_ message: String) -> some View {
+        VStack(spacing: 12) {
+            Spacer()
+            ProgressView()
+                .controlSize(.regular)
+            Text(message)
+                .font(.system(size: 13))
+                .foregroundStyle(theme.secondaryText)
+            Spacer()
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private func errorView(_ message: String) -> some View {
+        VStack(spacing: 8) {
+            Spacer()
+            Image(systemName: "exclamationmark.triangle")
+                .font(.title)
+                .foregroundStyle(theme.secondaryText)
+            Text(message)
+                .font(.system(size: 12))
+                .foregroundStyle(theme.secondaryText)
+                .multilineTextAlignment(.center)
+                .padding(.horizontal, 20)
+            Button("Retry") {
+                coordinator.search(query: query)
+            }
+            .controlSize(.small)
+            Spacer()
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private var noResultsView: some View {
+        VStack(spacing: 8) {
+            Spacer()
+            Image(systemName: "magnifyingglass")
+                .font(.system(size: 32))
+                .foregroundStyle(theme.tertiaryText)
+            Text("No results for \"\(query)\"")
+                .font(.system(size: 13))
+                .foregroundStyle(theme.secondaryText)
+            if !SlskdAPIClient.shared.isConfigured {
+                Text("Configure Soulseek in Settings to search beyond your library.")
+                    .font(.system(size: 11))
+                    .foregroundStyle(theme.tertiaryText)
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 20)
+            }
+            Spacer()
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private var resultsList: some View {
+        List {
+            // ChadMusic results
+            if !coordinator.cloudResults.isEmpty {
+                Section {
+                    ForEach(coordinator.cloudResults) { album in
+                        Button {
+                            navStack.append(.album(album))
+                        } label: {
+                            HStack {
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(album.title)
+                                        .font(.system(size: 13))
+                                        .foregroundStyle(theme.primaryText)
+                                        .lineLimit(1)
+                                    if let artist = album.artist {
+                                        Text(artist)
+                                            .font(.system(size: 11))
+                                            .foregroundStyle(theme.secondaryText)
+                                            .lineLimit(1)
+                                    }
+                                }
+                                Spacer()
+                                if let count = album.trackCount {
+                                    Text("\(count) tracks")
+                                        .font(.system(size: 11))
+                                        .foregroundStyle(theme.tertiaryText)
+                                }
+                                Image(systemName: "chevron.right")
+                                    .font(.caption2)
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                        }
+                        .buttonStyle(.plain)
+                    }
+                } header: {
+                    Label("In Your Library", systemImage: "cloud.fill")
+                        .font(.system(size: 11, weight: .semibold))
+                        .foregroundStyle(theme.accent)
+                }
+            }
+
+            // Soulseek results
+            if !coordinator.soulseekSources.isEmpty {
+                Section {
+                    ForEach(coordinator.soulseekSources) { source in
+                        SoulseekSourceRow(
+                            source: source,
+                            isDownloading: coordinator.downloadPhase.isActive,
+                            onDownload: {
+                                coordinator.downloadSource(
+                                    source.response,
+                                    artist: guessArtist(from: query),
+                                    albumName: query
+                                )
+                            }
+                        )
+                    }
+                } header: {
+                    Label("Available on Soulseek", systemImage: "arrow.down.circle.fill")
+                        .font(.system(size: 11, weight: .semibold))
+                        .foregroundStyle(Color(red: 1.0, green: 0.55, blue: 0.0))
+                }
+            }
+        }
+        .listStyle(.inset)
+    }
+
+    private var downloadStatusBar: some View {
+        HStack(spacing: 10) {
+            switch coordinator.downloadPhase {
+            case .downloading(let progress):
+                ProgressView(value: progress)
+                    .progressViewStyle(.linear)
+                    .frame(width: 80)
+                Text("Downloading... \(Int(progress * 100))%")
+                    .font(.system(size: 11))
+                    .foregroundStyle(theme.secondaryText)
+            case .importing:
+                ProgressView()
+                    .controlSize(.small)
+                Text("Importing to ChadMusic...")
+                    .font(.system(size: 11))
+                    .foregroundStyle(theme.secondaryText)
+            case .complete(let name):
+                Image(systemName: "checkmark.circle.fill")
+                    .foregroundStyle(.green)
+                Text("\(name) ready")
+                    .font(.system(size: 11))
+                    .foregroundStyle(theme.secondaryText)
+            case .failed(let msg):
+                Image(systemName: "exclamationmark.triangle.fill")
+                    .foregroundStyle(.red)
+                Text(msg)
+                    .font(.system(size: 11))
+                    .foregroundStyle(.red)
+                    .lineLimit(1)
+            case .idle:
+                EmptyView()
+            }
+
+            Spacer()
+
+            if coordinator.downloadPhase.isActive {
+                Button("Cancel") {
+                    coordinator.cancel()
+                }
+                .controlSize(.small)
+            } else if coordinator.downloadPhase != .idle {
+                Button("Dismiss") {
+                    coordinator.dismissDownload()
+                }
+                .controlSize(.small)
+            }
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 8)
+        .background(.ultraThinMaterial)
+    }
+
+    // MARK: - Helpers
+
+    /// Try to extract artist from query (e.g., "Pink Floyd - Wish You Were Here" → "Pink Floyd").
+    /// Falls back to the full query.
+    private func guessArtist(from query: String) -> String {
+        // If we have cloud results from the same search, use the first artist
+        if let artist = coordinator.cloudResults.first?.artist {
+            return artist
+        }
+        // Try "Artist - Album" format
+        if let dashRange = query.range(of: " - ") {
+            let artist = String(query[query.startIndex..<dashRange.lowerBound])
+                .trimmingCharacters(in: .whitespaces)
+            if !artist.isEmpty { return artist }
+        }
+        return query
+    }
+}
+
+// MARK: - Score Badge
+
+/// Color-coded quality score indicator for Soulseek search results.
+struct ScoreBadge: View {
+    let score: Int
+
+    private var color: Color {
+        if score >= 120 { return Color(red: 0.2, green: 0.9, blue: 0.4) }
+        if score >= 80 { return Color(red: 1.0, green: 0.8, blue: 0.0) }
+        return Color(red: 0.8, green: 0.3, blue: 0.3)
+    }
+
+    var body: some View {
+        Text("\(score)")
+            .font(.system(size: 11, weight: .bold, design: .monospaced))
+            .foregroundStyle(color)
+            .padding(.horizontal, 6)
+            .padding(.vertical, 2)
+            .background(
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(color.opacity(0.15))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 4)
+                            .stroke(color.opacity(0.3), lineWidth: 0.5)
+                    )
+            )
+    }
+}
+
+// MARK: - Soulseek Source Row
+
+/// A single Soulseek search result showing quality score, format, file count, and download button.
+struct SoulseekSourceRow: View {
+    let source: ScoredSoulseekSource
+    var isDownloading: Bool = false
+    let onDownload: () -> Void
+
+    @EnvironmentObject private var theme: AppTheme
+    @State private var isExpanded = false
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            HStack(spacing: 10) {
+                // Quality score badge
+                ScoreBadge(score: source.score)
+
+                // Source info
+                VStack(alignment: .leading, spacing: 2) {
+                    HStack(spacing: 6) {
+                        Text(source.formatDisplay)
+                            .font(.system(size: 11, weight: .bold, design: .monospaced))
+                            .foregroundStyle(formatColor)
+                        Text("\(source.audioFileCount) files")
+                            .font(.system(size: 11))
+                            .foregroundStyle(theme.secondaryText)
+                        Text(source.formattedTotalSize)
+                            .font(.system(size: 11))
+                            .foregroundStyle(theme.secondaryText)
+                        if source.response.hasFreeUploadSlot {
+                            Image(systemName: "bolt.fill")
+                                .font(.system(size: 9))
+                                .foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
+                        }
+                    }
+
+                    HStack(spacing: 6) {
+                        Text(source.response.username)
+                            .font(.system(size: 10))
+                            .foregroundStyle(theme.tertiaryText)
+                            .lineLimit(1)
+                        if source.response.queueLength > 0 {
+                            Text("Queue: \(source.response.queueLength)")
+                                .font(.system(size: 10))
+                                .foregroundStyle(theme.tertiaryText)
+                        }
+                    }
+                }
+
+                Spacer()
+
+                // Expand file list
+                Button {
+                    withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() }
+                } label: {
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
+                        .font(.system(size: 10))
+                        .foregroundStyle(theme.tertiaryText)
+                }
+                .buttonStyle(.plain)
+
+                // Download button
+                Button {
+                    onDownload()
+                } label: {
+                    Image(systemName: "arrow.down.circle.fill")
+                        .font(.system(size: 18))
+                        .foregroundStyle(source.score >= 80 ? theme.accent : theme.tertiaryText)
+                }
+                .buttonStyle(.plain)
+                .disabled(isDownloading || source.score < 30)
+                .help(source.score >= 80
+                      ? "Download this source"
+                      : "Quality too low (score: \(source.score))")
+            }
+            .padding(.vertical, 3)
+
+            // Expandable file list
+            if isExpanded {
+                VStack(alignment: .leading, spacing: 1) {
+                    ForEach(source.audioFiles, id: \.filename) { file in
+                        HStack(spacing: 8) {
+                            let name = file.filename
+                                .replacingOccurrences(of: "\\", with: "/")
+                                .split(separator: "/").last.map(String.init) ?? file.filename
+                            Text(name)
+                                .font(.system(size: 10, design: .monospaced))
+                                .foregroundStyle(theme.secondaryText)
+                                .lineLimit(1)
+                            Spacer()
+                            Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
+                                .font(.system(size: 10, design: .monospaced))
+                                .foregroundStyle(theme.tertiaryText)
+                            if let br = file.bitRate, br > 0 {
+                                Text("\(br)k")
+                                    .font(.system(size: 10, design: .monospaced))
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                        }
+                    }
+                }
+                .padding(.leading, 40)
+                .padding(.vertical, 4)
+            }
+        }
+        .opacity(source.score >= 80 ? 1.0 : 0.5)
+    }
+
+    private var formatColor: Color {
+        switch source.bestFormat {
+        case "FLAC", "WAV", "AIFF", "AIF": Color(red: 0.2, green: 0.9, blue: 0.4)
+        case "APE", "WV", "M4A": Color(red: 0.3, green: 0.8, blue: 0.95)
+        case "MP3": Color(red: 1.0, green: 0.8, blue: 0.0)
+        default: theme.tertiaryText
+        }
+    }
+}