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