| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- 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
- }
- }
- }
|