UnifiedSearchResultsView.swift 16 KB

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