| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- 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()
- @State private var editableQuery: String = ""
- @FocusState private var isSearchFieldFocused: Bool
- @EnvironmentObject private var theme: AppTheme
- var body: some View {
- VStack(spacing: 0) {
- // Phase indicator
- searchPhaseHeader
- Divider()
- // Results
- Group {
- switch coordinator.phase {
- case .idle:
- VStack(spacing: 8) {
- Spacer()
- Image(systemName: "magnifyingglass")
- .font(.system(size: 32))
- .foregroundStyle(theme.tertiaryText)
- Text("Type a query and press Enter")
- .font(.system(size: 13))
- .foregroundStyle(theme.secondaryText)
- Spacer()
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- 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 {
- editableQuery = query
- if query.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 {
- coordinator.search(query: query)
- } else {
- isSearchFieldFocused = true
- }
- }
- }
- // MARK: - Subviews
- private var searchPhaseHeader: some View {
- HStack(spacing: 8) {
- Image(systemName: "magnifyingglass")
- .foregroundStyle(theme.secondaryText)
- TextField("Search library & Soulseek...", text: $editableQuery)
- .textFieldStyle(.plain)
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(theme.primaryText)
- .focused($isSearchFieldFocused)
- .onSubmit {
- let q = editableQuery.trimmingCharacters(in: .whitespacesAndNewlines)
- guard q.count >= 2 else { return }
- coordinator.search(query: q)
- }
- 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: editableQuery)
- }
- .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 \"\(editableQuery)\"")
- .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,
- transfers: coordinator.activeTransfers,
- onDownload: {
- coordinator.downloadSource(
- source.albumSource,
- artist: source.artistGuess ?? guessArtist(from: editableQuery),
- albumName: source.albumName
- )
- }
- )
- .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.cancelDownload()
- }
- .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 — one directory (album) from a user.
- struct SoulseekSourceRow: View {
- let source: ScoredSoulseekSource
- var isDownloading: Bool = false
- var transfers: [String: SlskdTransfer] = [:]
- 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)
- // Album info
- VStack(alignment: .leading, spacing: 2) {
- // Line 1: Artist — Album
- HStack(spacing: 0) {
- if let artist = source.artistGuess {
- Text(artist)
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(theme.secondaryText)
- .lineLimit(1)
- Text(" — ")
- .font(.system(size: 12))
- .foregroundStyle(theme.tertiaryText)
- }
- Text(source.albumName)
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(theme.primaryText)
- .lineLimit(1)
- }
- // Line 2: Format · files · size · ⚡
- HStack(spacing: 6) {
- Text(source.formatDisplay)
- .font(.system(size: 11, weight: .bold, design: .monospaced))
- .foregroundStyle(formatColor)
- Text("·")
- .foregroundStyle(theme.tertiaryText)
- Text("\(source.audioFileCount) files")
- .font(.system(size: 11))
- .foregroundStyle(theme.secondaryText)
- Text("·")
- .foregroundStyle(theme.tertiaryText)
- Text(source.formattedTotalSize)
- .font(.system(size: 11))
- .foregroundStyle(theme.secondaryText)
- if source.albumSource.hasFreeUploadSlot {
- Image(systemName: "bolt.fill")
- .font(.system(size: 9))
- .foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
- }
- }
- // Line 3: Username · Queue
- HStack(spacing: 6) {
- Text(source.username)
- .font(.system(size: 10))
- .foregroundStyle(theme.tertiaryText)
- .lineLimit(1)
- if source.albumSource.queueLength > 0 {
- Text("Queue: \(source.albumSource.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 \(source.albumName)"
- : "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) {
- // Transfer status icon
- if let transfer = transfers[file.filename] {
- transferIcon(transfer)
- }
- 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()
- // Per-file progress or size
- if let transfer = transfers[file.filename], !transfer.isComplete, !transfer.isFailed {
- Text("\(Int(transfer.percentComplete))%")
- .font(.system(size: 10, weight: .medium, design: .monospaced))
- .foregroundStyle(theme.accent)
- } else {
- 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
- }
- }
- @ViewBuilder
- private func transferIcon(_ transfer: SlskdTransfer) -> some View {
- if transfer.isComplete {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 9))
- .foregroundStyle(.green)
- } else if transfer.isFailed {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 9))
- .foregroundStyle(.red)
- } else {
- ProgressView()
- .controlSize(.mini)
- }
- }
- }
|