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 ) } ) .draggable(source.dragRepresentation) } } 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..= 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 } } }