UnifiedSearchResultsView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import SwiftUI
  2. // MARK: - Unified Search Results
  3. /// Shows ChadMusic library results and Soulseek sources in a single view.
  4. /// Pushed onto CloudBrowserView's navStack when user submits a search query.
  5. struct UnifiedSearchResultsView: View {
  6. let query: String
  7. @Binding var navStack: [CloudNavDestination]
  8. @State private var coordinator = UnifiedSearchCoordinator()
  9. @State private var editableQuery: String = ""
  10. @FocusState private var isSearchFieldFocused: Bool
  11. @EnvironmentObject private var theme: AppTheme
  12. var body: some View {
  13. VStack(spacing: 0) {
  14. // Phase indicator
  15. searchPhaseHeader
  16. Divider()
  17. // Results
  18. Group {
  19. switch coordinator.phase {
  20. case .idle:
  21. VStack(spacing: 8) {
  22. Spacer()
  23. Image(systemName: "magnifyingglass")
  24. .font(.system(size: 32))
  25. .foregroundStyle(theme.tertiaryText)
  26. Text("Type a query and press Enter")
  27. .font(.system(size: 13))
  28. .foregroundStyle(theme.secondaryText)
  29. Spacer()
  30. }
  31. .frame(maxWidth: .infinity, maxHeight: .infinity)
  32. case .searchingCloud:
  33. searchingView("Searching your library...")
  34. case .searchingSoulseek:
  35. searchingView("Not in library. Searching Soulseek...")
  36. case .error(let msg):
  37. errorView(msg)
  38. case .done:
  39. if coordinator.cloudResults.isEmpty && coordinator.soulseekSources.isEmpty {
  40. noResultsView
  41. } else {
  42. resultsList
  43. }
  44. }
  45. }
  46. // Download progress bar
  47. if coordinator.downloadPhase != .idle {
  48. downloadStatusBar
  49. }
  50. }
  51. .task {
  52. editableQuery = query
  53. if query.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 {
  54. coordinator.search(query: query)
  55. } else {
  56. isSearchFieldFocused = true
  57. }
  58. }
  59. }
  60. // MARK: - Subviews
  61. private var searchPhaseHeader: some View {
  62. HStack(spacing: 8) {
  63. Image(systemName: "magnifyingglass")
  64. .foregroundStyle(theme.secondaryText)
  65. TextField("Search library & Soulseek...", text: $editableQuery)
  66. .textFieldStyle(.plain)
  67. .font(.system(size: 13, weight: .medium))
  68. .foregroundStyle(theme.primaryText)
  69. .focused($isSearchFieldFocused)
  70. .onSubmit {
  71. let q = editableQuery.trimmingCharacters(in: .whitespacesAndNewlines)
  72. guard q.count >= 2 else { return }
  73. coordinator.search(query: q)
  74. }
  75. Spacer()
  76. if coordinator.phase == .searchingCloud || coordinator.phase == .searchingSoulseek {
  77. ProgressView()
  78. .controlSize(.small)
  79. }
  80. }
  81. .padding(.horizontal, 12)
  82. .padding(.vertical, 8)
  83. .background(theme.toolbarBackground.opacity(0.3))
  84. }
  85. private func searchingView(_ message: String) -> some View {
  86. VStack(spacing: 12) {
  87. Spacer()
  88. ProgressView()
  89. .controlSize(.regular)
  90. Text(message)
  91. .font(.system(size: 13))
  92. .foregroundStyle(theme.secondaryText)
  93. Spacer()
  94. }
  95. .frame(maxWidth: .infinity, maxHeight: .infinity)
  96. }
  97. private func errorView(_ message: String) -> some View {
  98. VStack(spacing: 8) {
  99. Spacer()
  100. Image(systemName: "exclamationmark.triangle")
  101. .font(.title)
  102. .foregroundStyle(theme.secondaryText)
  103. Text(message)
  104. .font(.system(size: 12))
  105. .foregroundStyle(theme.secondaryText)
  106. .multilineTextAlignment(.center)
  107. .padding(.horizontal, 20)
  108. Button("Retry") {
  109. coordinator.search(query: editableQuery)
  110. }
  111. .controlSize(.small)
  112. Spacer()
  113. }
  114. .frame(maxWidth: .infinity, maxHeight: .infinity)
  115. }
  116. private var noResultsView: some View {
  117. VStack(spacing: 8) {
  118. Spacer()
  119. Image(systemName: "magnifyingglass")
  120. .font(.system(size: 32))
  121. .foregroundStyle(theme.tertiaryText)
  122. Text("No results for \"\(editableQuery)\"")
  123. .font(.system(size: 13))
  124. .foregroundStyle(theme.secondaryText)
  125. if !SlskdAPIClient.shared.isConfigured {
  126. Text("Configure Soulseek in Settings to search beyond your library.")
  127. .font(.system(size: 11))
  128. .foregroundStyle(theme.tertiaryText)
  129. .multilineTextAlignment(.center)
  130. .padding(.horizontal, 20)
  131. }
  132. Spacer()
  133. }
  134. .frame(maxWidth: .infinity, maxHeight: .infinity)
  135. }
  136. private var resultsList: some View {
  137. List {
  138. // ChadMusic results
  139. if !coordinator.cloudResults.isEmpty {
  140. Section {
  141. ForEach(coordinator.cloudResults) { album in
  142. Button {
  143. navStack.append(.album(album))
  144. } label: {
  145. HStack {
  146. VStack(alignment: .leading, spacing: 2) {
  147. Text(album.title)
  148. .font(.system(size: 13))
  149. .foregroundStyle(theme.primaryText)
  150. .lineLimit(1)
  151. if let artist = album.artist {
  152. Text(artist)
  153. .font(.system(size: 11))
  154. .foregroundStyle(theme.secondaryText)
  155. .lineLimit(1)
  156. }
  157. }
  158. Spacer()
  159. if let count = album.trackCount {
  160. Text("\(count) tracks")
  161. .font(.system(size: 11))
  162. .foregroundStyle(theme.tertiaryText)
  163. }
  164. Image(systemName: "chevron.right")
  165. .font(.caption2)
  166. .foregroundStyle(theme.tertiaryText)
  167. }
  168. }
  169. .buttonStyle(.plain)
  170. }
  171. } header: {
  172. Label("In Your Library", systemImage: "cloud.fill")
  173. .font(.system(size: 11, weight: .semibold))
  174. .foregroundStyle(theme.accent)
  175. }
  176. }
  177. // Soulseek results
  178. if !coordinator.soulseekSources.isEmpty {
  179. Section {
  180. ForEach(coordinator.soulseekSources) { source in
  181. SoulseekSourceRow(
  182. source: source,
  183. isDownloading: coordinator.downloadPhase.isActive,
  184. transfers: coordinator.activeTransfers,
  185. onDownload: {
  186. coordinator.downloadSource(
  187. source.albumSource,
  188. artist: source.artistGuess ?? guessArtist(from: editableQuery),
  189. albumName: source.albumName
  190. )
  191. }
  192. )
  193. .draggable(source.dragRepresentation)
  194. }
  195. } header: {
  196. Label("Available on Soulseek", systemImage: "arrow.down.circle.fill")
  197. .font(.system(size: 11, weight: .semibold))
  198. .foregroundStyle(Color(red: 1.0, green: 0.55, blue: 0.0))
  199. }
  200. }
  201. }
  202. .listStyle(.inset)
  203. }
  204. private var downloadStatusBar: some View {
  205. HStack(spacing: 10) {
  206. switch coordinator.downloadPhase {
  207. case .downloading(let progress):
  208. ProgressView(value: progress)
  209. .progressViewStyle(.linear)
  210. .frame(width: 80)
  211. Text("Downloading... \(Int(progress * 100))%")
  212. .font(.system(size: 11))
  213. .foregroundStyle(theme.secondaryText)
  214. case .importing:
  215. ProgressView()
  216. .controlSize(.small)
  217. Text("Importing to ChadMusic...")
  218. .font(.system(size: 11))
  219. .foregroundStyle(theme.secondaryText)
  220. case .complete(let name):
  221. Image(systemName: "checkmark.circle.fill")
  222. .foregroundStyle(.green)
  223. Text("\(name) ready")
  224. .font(.system(size: 11))
  225. .foregroundStyle(theme.secondaryText)
  226. case .failed(let msg):
  227. Image(systemName: "exclamationmark.triangle.fill")
  228. .foregroundStyle(.red)
  229. Text(msg)
  230. .font(.system(size: 11))
  231. .foregroundStyle(.red)
  232. .lineLimit(1)
  233. case .idle:
  234. EmptyView()
  235. }
  236. Spacer()
  237. if coordinator.downloadPhase.isActive {
  238. Button("Cancel") {
  239. coordinator.cancelDownload()
  240. }
  241. .controlSize(.small)
  242. } else if coordinator.downloadPhase != .idle {
  243. Button("Dismiss") {
  244. coordinator.dismissDownload()
  245. }
  246. .controlSize(.small)
  247. }
  248. }
  249. .padding(.horizontal, 12)
  250. .padding(.vertical, 8)
  251. .background(.ultraThinMaterial)
  252. }
  253. // MARK: - Helpers
  254. /// Try to extract artist from query (e.g., "Pink Floyd - Wish You Were Here" → "Pink Floyd").
  255. /// Falls back to the full query.
  256. private func guessArtist(from query: String) -> String {
  257. // If we have cloud results from the same search, use the first artist
  258. if let artist = coordinator.cloudResults.first?.artist {
  259. return artist
  260. }
  261. // Try "Artist - Album" format
  262. if let dashRange = query.range(of: " - ") {
  263. let artist = String(query[query.startIndex..<dashRange.lowerBound])
  264. .trimmingCharacters(in: .whitespaces)
  265. if !artist.isEmpty { return artist }
  266. }
  267. return query
  268. }
  269. }
  270. // MARK: - Score Badge
  271. /// Color-coded quality score indicator for Soulseek search results.
  272. struct ScoreBadge: View {
  273. let score: Int
  274. private var color: Color {
  275. if score >= 120 { return Color(red: 0.2, green: 0.9, blue: 0.4) }
  276. if score >= 80 { return Color(red: 1.0, green: 0.8, blue: 0.0) }
  277. return Color(red: 0.8, green: 0.3, blue: 0.3)
  278. }
  279. var body: some View {
  280. Text("\(score)")
  281. .font(.system(size: 11, weight: .bold, design: .monospaced))
  282. .foregroundStyle(color)
  283. .padding(.horizontal, 6)
  284. .padding(.vertical, 2)
  285. .background(
  286. RoundedRectangle(cornerRadius: 4)
  287. .fill(color.opacity(0.15))
  288. .overlay(
  289. RoundedRectangle(cornerRadius: 4)
  290. .stroke(color.opacity(0.3), lineWidth: 0.5)
  291. )
  292. )
  293. }
  294. }
  295. // MARK: - Soulseek Source Row
  296. /// A single Soulseek search result — one directory (album) from a user.
  297. struct SoulseekSourceRow: View {
  298. let source: ScoredSoulseekSource
  299. var isDownloading: Bool = false
  300. var transfers: [String: SlskdTransfer] = [:]
  301. let onDownload: () -> Void
  302. @EnvironmentObject private var theme: AppTheme
  303. @State private var isExpanded = false
  304. var body: some View {
  305. VStack(alignment: .leading, spacing: 0) {
  306. HStack(spacing: 10) {
  307. // Quality score badge
  308. ScoreBadge(score: source.score)
  309. // Album info
  310. VStack(alignment: .leading, spacing: 2) {
  311. // Line 1: Artist — Album
  312. HStack(spacing: 0) {
  313. if let artist = source.artistGuess {
  314. Text(artist)
  315. .font(.system(size: 12, weight: .medium))
  316. .foregroundStyle(theme.secondaryText)
  317. .lineLimit(1)
  318. Text(" — ")
  319. .font(.system(size: 12))
  320. .foregroundStyle(theme.tertiaryText)
  321. }
  322. Text(source.albumName)
  323. .font(.system(size: 12, weight: .semibold))
  324. .foregroundStyle(theme.primaryText)
  325. .lineLimit(1)
  326. }
  327. // Line 2: Format · files · size · ⚡
  328. HStack(spacing: 6) {
  329. Text(source.formatDisplay)
  330. .font(.system(size: 11, weight: .bold, design: .monospaced))
  331. .foregroundStyle(formatColor)
  332. Text("·")
  333. .foregroundStyle(theme.tertiaryText)
  334. Text("\(source.audioFileCount) files")
  335. .font(.system(size: 11))
  336. .foregroundStyle(theme.secondaryText)
  337. Text("·")
  338. .foregroundStyle(theme.tertiaryText)
  339. Text(source.formattedTotalSize)
  340. .font(.system(size: 11))
  341. .foregroundStyle(theme.secondaryText)
  342. if source.albumSource.hasFreeUploadSlot {
  343. Image(systemName: "bolt.fill")
  344. .font(.system(size: 9))
  345. .foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
  346. }
  347. }
  348. // Line 3: Username · Queue
  349. HStack(spacing: 6) {
  350. Text(source.username)
  351. .font(.system(size: 10))
  352. .foregroundStyle(theme.tertiaryText)
  353. .lineLimit(1)
  354. if source.albumSource.queueLength > 0 {
  355. Text("Queue: \(source.albumSource.queueLength)")
  356. .font(.system(size: 10))
  357. .foregroundStyle(theme.tertiaryText)
  358. }
  359. }
  360. }
  361. Spacer()
  362. // Expand file list
  363. Button {
  364. withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() }
  365. } label: {
  366. Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
  367. .font(.system(size: 10))
  368. .foregroundStyle(theme.tertiaryText)
  369. }
  370. .buttonStyle(.plain)
  371. // Download button
  372. Button {
  373. onDownload()
  374. } label: {
  375. Image(systemName: "arrow.down.circle.fill")
  376. .font(.system(size: 18))
  377. .foregroundStyle(source.score >= 80 ? theme.accent : theme.tertiaryText)
  378. }
  379. .buttonStyle(.plain)
  380. .disabled(isDownloading || source.score < 30)
  381. .help(source.score >= 80
  382. ? "Download \(source.albumName)"
  383. : "Quality too low (score: \(source.score))")
  384. }
  385. .padding(.vertical, 3)
  386. // Expandable file list
  387. if isExpanded {
  388. VStack(alignment: .leading, spacing: 1) {
  389. ForEach(source.audioFiles, id: \.filename) { file in
  390. HStack(spacing: 8) {
  391. // Transfer status icon
  392. if let transfer = transfers[file.filename] {
  393. transferIcon(transfer)
  394. }
  395. let name = file.filename
  396. .replacingOccurrences(of: "\\", with: "/")
  397. .split(separator: "/").last.map(String.init) ?? file.filename
  398. Text(name)
  399. .font(.system(size: 10, design: .monospaced))
  400. .foregroundStyle(theme.secondaryText)
  401. .lineLimit(1)
  402. Spacer()
  403. // Per-file progress or size
  404. if let transfer = transfers[file.filename], !transfer.isComplete, !transfer.isFailed {
  405. Text("\(Int(transfer.percentComplete))%")
  406. .font(.system(size: 10, weight: .medium, design: .monospaced))
  407. .foregroundStyle(theme.accent)
  408. } else {
  409. Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
  410. .font(.system(size: 10, design: .monospaced))
  411. .foregroundStyle(theme.tertiaryText)
  412. }
  413. if let br = file.bitRate, br > 0 {
  414. Text("\(br)k")
  415. .font(.system(size: 10, design: .monospaced))
  416. .foregroundStyle(theme.tertiaryText)
  417. }
  418. }
  419. }
  420. }
  421. .padding(.leading, 40)
  422. .padding(.vertical, 4)
  423. }
  424. }
  425. .opacity(source.score >= 80 ? 1.0 : 0.5)
  426. }
  427. private var formatColor: Color {
  428. switch source.bestFormat {
  429. case "FLAC", "WAV", "AIFF", "AIF": Color(red: 0.2, green: 0.9, blue: 0.4)
  430. case "APE", "WV", "M4A": Color(red: 0.3, green: 0.8, blue: 0.95)
  431. case "MP3": Color(red: 1.0, green: 0.8, blue: 0.0)
  432. default: theme.tertiaryText
  433. }
  434. }
  435. @ViewBuilder
  436. private func transferIcon(_ transfer: SlskdTransfer) -> some View {
  437. if transfer.isComplete {
  438. Image(systemName: "checkmark.circle.fill")
  439. .font(.system(size: 9))
  440. .foregroundStyle(.green)
  441. } else if transfer.isFailed {
  442. Image(systemName: "xmark.circle.fill")
  443. .font(.system(size: 9))
  444. .foregroundStyle(.red)
  445. } else {
  446. ProgressView()
  447. .controlSize(.mini)
  448. }
  449. }
  450. }