UnifiedSearchResultsView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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. .draggable(source.dragRepresentation)
  170. }
  171. } header: {
  172. Label("Available on Soulseek", systemImage: "arrow.down.circle.fill")
  173. .font(.system(size: 11, weight: .semibold))
  174. .foregroundStyle(Color(red: 1.0, green: 0.55, blue: 0.0))
  175. }
  176. }
  177. }
  178. .listStyle(.inset)
  179. }
  180. private var downloadStatusBar: some View {
  181. HStack(spacing: 10) {
  182. switch coordinator.downloadPhase {
  183. case .downloading(let progress):
  184. ProgressView(value: progress)
  185. .progressViewStyle(.linear)
  186. .frame(width: 80)
  187. Text("Downloading... \(Int(progress * 100))%")
  188. .font(.system(size: 11))
  189. .foregroundStyle(theme.secondaryText)
  190. case .importing:
  191. ProgressView()
  192. .controlSize(.small)
  193. Text("Importing to ChadMusic...")
  194. .font(.system(size: 11))
  195. .foregroundStyle(theme.secondaryText)
  196. case .complete(let name):
  197. Image(systemName: "checkmark.circle.fill")
  198. .foregroundStyle(.green)
  199. Text("\(name) ready")
  200. .font(.system(size: 11))
  201. .foregroundStyle(theme.secondaryText)
  202. case .failed(let msg):
  203. Image(systemName: "exclamationmark.triangle.fill")
  204. .foregroundStyle(.red)
  205. Text(msg)
  206. .font(.system(size: 11))
  207. .foregroundStyle(.red)
  208. .lineLimit(1)
  209. case .idle:
  210. EmptyView()
  211. }
  212. Spacer()
  213. if coordinator.downloadPhase.isActive {
  214. Button("Cancel") {
  215. coordinator.cancel()
  216. }
  217. .controlSize(.small)
  218. } else if coordinator.downloadPhase != .idle {
  219. Button("Dismiss") {
  220. coordinator.dismissDownload()
  221. }
  222. .controlSize(.small)
  223. }
  224. }
  225. .padding(.horizontal, 12)
  226. .padding(.vertical, 8)
  227. .background(.ultraThinMaterial)
  228. }
  229. // MARK: - Helpers
  230. /// Try to extract artist from query (e.g., "Pink Floyd - Wish You Were Here" → "Pink Floyd").
  231. /// Falls back to the full query.
  232. private func guessArtist(from query: String) -> String {
  233. // If we have cloud results from the same search, use the first artist
  234. if let artist = coordinator.cloudResults.first?.artist {
  235. return artist
  236. }
  237. // Try "Artist - Album" format
  238. if let dashRange = query.range(of: " - ") {
  239. let artist = String(query[query.startIndex..<dashRange.lowerBound])
  240. .trimmingCharacters(in: .whitespaces)
  241. if !artist.isEmpty { return artist }
  242. }
  243. return query
  244. }
  245. }
  246. // MARK: - Score Badge
  247. /// Color-coded quality score indicator for Soulseek search results.
  248. struct ScoreBadge: View {
  249. let score: Int
  250. private var color: Color {
  251. if score >= 120 { return Color(red: 0.2, green: 0.9, blue: 0.4) }
  252. if score >= 80 { return Color(red: 1.0, green: 0.8, blue: 0.0) }
  253. return Color(red: 0.8, green: 0.3, blue: 0.3)
  254. }
  255. var body: some View {
  256. Text("\(score)")
  257. .font(.system(size: 11, weight: .bold, design: .monospaced))
  258. .foregroundStyle(color)
  259. .padding(.horizontal, 6)
  260. .padding(.vertical, 2)
  261. .background(
  262. RoundedRectangle(cornerRadius: 4)
  263. .fill(color.opacity(0.15))
  264. .overlay(
  265. RoundedRectangle(cornerRadius: 4)
  266. .stroke(color.opacity(0.3), lineWidth: 0.5)
  267. )
  268. )
  269. }
  270. }
  271. // MARK: - Soulseek Source Row
  272. /// A single Soulseek search result showing quality score, format, file count, and download button.
  273. struct SoulseekSourceRow: View {
  274. let source: ScoredSoulseekSource
  275. var isDownloading: Bool = false
  276. let onDownload: () -> Void
  277. @EnvironmentObject private var theme: AppTheme
  278. @State private var isExpanded = false
  279. var body: some View {
  280. VStack(alignment: .leading, spacing: 0) {
  281. HStack(spacing: 10) {
  282. // Quality score badge
  283. ScoreBadge(score: source.score)
  284. // Source info
  285. VStack(alignment: .leading, spacing: 2) {
  286. HStack(spacing: 6) {
  287. Text(source.formatDisplay)
  288. .font(.system(size: 11, weight: .bold, design: .monospaced))
  289. .foregroundStyle(formatColor)
  290. Text("\(source.audioFileCount) files")
  291. .font(.system(size: 11))
  292. .foregroundStyle(theme.secondaryText)
  293. Text(source.formattedTotalSize)
  294. .font(.system(size: 11))
  295. .foregroundStyle(theme.secondaryText)
  296. if source.response.hasFreeUploadSlot {
  297. Image(systemName: "bolt.fill")
  298. .font(.system(size: 9))
  299. .foregroundStyle(Color(red: 0.2, green: 0.9, blue: 0.4))
  300. }
  301. }
  302. HStack(spacing: 6) {
  303. Text(source.response.username)
  304. .font(.system(size: 10))
  305. .foregroundStyle(theme.tertiaryText)
  306. .lineLimit(1)
  307. if source.response.queueLength > 0 {
  308. Text("Queue: \(source.response.queueLength)")
  309. .font(.system(size: 10))
  310. .foregroundStyle(theme.tertiaryText)
  311. }
  312. }
  313. }
  314. Spacer()
  315. // Expand file list
  316. Button {
  317. withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() }
  318. } label: {
  319. Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
  320. .font(.system(size: 10))
  321. .foregroundStyle(theme.tertiaryText)
  322. }
  323. .buttonStyle(.plain)
  324. // Download button
  325. Button {
  326. onDownload()
  327. } label: {
  328. Image(systemName: "arrow.down.circle.fill")
  329. .font(.system(size: 18))
  330. .foregroundStyle(source.score >= 80 ? theme.accent : theme.tertiaryText)
  331. }
  332. .buttonStyle(.plain)
  333. .disabled(isDownloading || source.score < 30)
  334. .help(source.score >= 80
  335. ? "Download this source"
  336. : "Quality too low (score: \(source.score))")
  337. }
  338. .padding(.vertical, 3)
  339. // Expandable file list
  340. if isExpanded {
  341. VStack(alignment: .leading, spacing: 1) {
  342. ForEach(source.audioFiles, id: \.filename) { file in
  343. HStack(spacing: 8) {
  344. let name = file.filename
  345. .replacingOccurrences(of: "\\", with: "/")
  346. .split(separator: "/").last.map(String.init) ?? file.filename
  347. Text(name)
  348. .font(.system(size: 10, design: .monospaced))
  349. .foregroundStyle(theme.secondaryText)
  350. .lineLimit(1)
  351. Spacer()
  352. Text(ByteCountFormatter.string(fromByteCount: file.size, countStyle: .file))
  353. .font(.system(size: 10, design: .monospaced))
  354. .foregroundStyle(theme.tertiaryText)
  355. if let br = file.bitRate, br > 0 {
  356. Text("\(br)k")
  357. .font(.system(size: 10, design: .monospaced))
  358. .foregroundStyle(theme.tertiaryText)
  359. }
  360. }
  361. }
  362. }
  363. .padding(.leading, 40)
  364. .padding(.vertical, 4)
  365. }
  366. }
  367. .opacity(source.score >= 80 ? 1.0 : 0.5)
  368. }
  369. private var formatColor: Color {
  370. switch source.bestFormat {
  371. case "FLAC", "WAV", "AIFF", "AIF": Color(red: 0.2, green: 0.9, blue: 0.4)
  372. case "APE", "WV", "M4A": Color(red: 0.3, green: 0.8, blue: 0.95)
  373. case "MP3": Color(red: 1.0, green: 0.8, blue: 0.0)
  374. default: theme.tertiaryText
  375. }
  376. }
  377. }