CloudBrowserView.swift 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133
  1. import SwiftData
  2. import SwiftUI
  3. /// Navigation destination within the cloud browser — managed manually to avoid
  4. /// NavigationSplitView capturing nested NavigationLink pushes on macOS.
  5. enum CloudNavDestination: Hashable {
  6. case category(ChadCategoryType)
  7. case album(ChadAlbum)
  8. case filter(CategoryFilter)
  9. case search(query: String)
  10. }
  11. /// Cloud library browser — navigate categories → albums → tracks from Chad Music server.
  12. struct CloudBrowserView: View {
  13. /// Which library section to start at (nil = root category grid).
  14. let initialDestination: LibraryDestination?
  15. @Environment(PlayerViewModel.self) private var playerVM
  16. @EnvironmentObject private var theme: AppTheme
  17. @State private var apiClient = ChadMusicAPIClient.shared
  18. @State private var uploadService = UploadService.shared
  19. @State private var orchestrator = SoulseekOrchestrator.shared
  20. @State private var navStack: [CloudNavDestination] = []
  21. init(initialDestination: LibraryDestination? = nil) {
  22. self.initialDestination = initialDestination
  23. }
  24. var body: some View {
  25. if !apiClient.isConfigured {
  26. CloudNotConfiguredView()
  27. } else {
  28. VStack(spacing: 0) {
  29. if let current = navStack.last {
  30. // Back button header for all detail views
  31. CloudNavHeader(navStack: $navStack, title: {
  32. switch current {
  33. case .category(let cat): cat.displayName
  34. case .album(let album): album.title
  35. case .filter(let filter): filter.value
  36. case .search(let query): "Search: \(query)"
  37. }
  38. }())
  39. Divider()
  40. switch current {
  41. case .category(let cat):
  42. CategoryDetailView(apiClient: apiClient, category: cat, navStack: $navStack)
  43. case .album(let album):
  44. AlbumDetailView(apiClient: apiClient, album: album, navStack: $navStack)
  45. case .filter(let filter):
  46. FilteredAlbumsView(apiClient: apiClient, filter: filter, navStack: $navStack)
  47. case .search(let query):
  48. UnifiedSearchResultsView(query: query, navStack: $navStack)
  49. }
  50. } else {
  51. CategoryListView(apiClient: apiClient, uploadService: uploadService, navStack: $navStack)
  52. }
  53. // Soulseek status banner — appears at bottom during active pipeline
  54. SoulseekStatusBanner(orchestrator: orchestrator)
  55. }
  56. .onAppear {
  57. if let dest = initialDestination {
  58. navStack = dest.initialNavStack
  59. }
  60. }
  61. }
  62. }
  63. }
  64. // MARK: - Not Configured Prompt
  65. private struct CloudNotConfiguredView: View {
  66. var body: some View {
  67. VStack(spacing: 16) {
  68. Spacer()
  69. Image(systemName: "cloud.fill")
  70. .font(.system(size: 48))
  71. .foregroundStyle(.tertiary)
  72. Text("Chad Music Not Configured")
  73. .font(.title3)
  74. .foregroundStyle(.secondary)
  75. Text("Set your server URL and API key in Settings → Chad Music.")
  76. .font(.callout)
  77. .foregroundStyle(.tertiary)
  78. .multilineTextAlignment(.center)
  79. Spacer()
  80. }
  81. .frame(maxWidth: .infinity, maxHeight: .infinity)
  82. }
  83. }
  84. // MARK: - Navigation Header (back button + title)
  85. private struct CloudNavHeader: View {
  86. @Binding var navStack: [CloudNavDestination]
  87. let title: String
  88. @EnvironmentObject private var theme: AppTheme
  89. var body: some View {
  90. HStack(spacing: 6) {
  91. Button {
  92. navStack.removeLast()
  93. } label: {
  94. Image(systemName: "chevron.left")
  95. .font(.system(size: 14, weight: .semibold))
  96. .foregroundStyle(theme.primaryText)
  97. }
  98. .buttonStyle(.plain)
  99. Text(title)
  100. .font(.system(size: 13, weight: .semibold))
  101. .foregroundStyle(theme.primaryText)
  102. .lineLimit(1)
  103. Spacer()
  104. }
  105. .padding(.horizontal, 12)
  106. .padding(.vertical, 8)
  107. }
  108. }
  109. // MARK: - Category List
  110. private struct CategoryListView: View {
  111. let apiClient: ChadMusicAPIClient
  112. let uploadService: UploadService
  113. @Binding var navStack: [CloudNavDestination]
  114. @State private var searchText: String = ""
  115. @EnvironmentObject private var theme: AppTheme
  116. /// Show albums and artists by default — the most useful categories.
  117. private let defaultCategories: [ChadCategoryType] = [.album, .artist, .genre, .year]
  118. var body: some View {
  119. VStack(alignment: .leading, spacing: 0) {
  120. // Header with stats + upload button
  121. CloudHeaderView(apiClient: apiClient, uploadService: uploadService)
  122. // Search bar
  123. HStack(spacing: 8) {
  124. Image(systemName: "magnifyingglass")
  125. .font(.system(size: 12))
  126. .foregroundStyle(theme.tertiaryText)
  127. TextField("Search library & Soulseek...", text: $searchText)
  128. .textFieldStyle(.plain)
  129. .font(.system(size: 13))
  130. .onSubmit {
  131. let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
  132. guard query.count >= 2 else { return }
  133. navStack.append(.search(query: query))
  134. }
  135. if !searchText.isEmpty {
  136. Button {
  137. searchText = ""
  138. } label: {
  139. Image(systemName: "xmark.circle.fill")
  140. .font(.system(size: 12))
  141. .foregroundStyle(theme.tertiaryText)
  142. }
  143. .buttonStyle(.plain)
  144. }
  145. }
  146. .padding(.horizontal, 12)
  147. .padding(.vertical, 8)
  148. .background(theme.toolbarBackground.opacity(0.3))
  149. Divider()
  150. List {
  151. Section("Browse") {
  152. ForEach(defaultCategories) { category in
  153. Button {
  154. navStack.append(.category(category))
  155. } label: {
  156. Label(category.displayName, systemImage: category.icon)
  157. }
  158. .buttonStyle(.plain)
  159. }
  160. }
  161. Section("More") {
  162. ForEach(ChadCategoryType.allCases.filter { !defaultCategories.contains($0) }) { category in
  163. Button {
  164. navStack.append(.category(category))
  165. } label: {
  166. Label(category.displayName, systemImage: category.icon)
  167. }
  168. .buttonStyle(.plain)
  169. }
  170. }
  171. }
  172. .listStyle(.sidebar)
  173. }
  174. }
  175. }
  176. // MARK: - Cloud Header (stats bar + upload)
  177. private struct CloudHeaderView: View {
  178. let apiClient: ChadMusicAPIClient
  179. let uploadService: UploadService
  180. @State private var stats: ChadStats?
  181. @State private var statsError = false
  182. @State private var showUploadError = false
  183. var body: some View {
  184. HStack(spacing: 8) {
  185. Image(systemName: "cloud.fill")
  186. .foregroundStyle(.secondary)
  187. if statsError {
  188. Text("Could not load stats")
  189. .font(.caption)
  190. .foregroundStyle(.tertiary)
  191. } else if let stats {
  192. let parts = [
  193. stats.tracks.map { "\($0) tracks" },
  194. stats.albums.map { "\($0) albums" },
  195. stats.artists.map { "\($0) artists" },
  196. ].compactMap { $0 }
  197. Text(parts.joined(separator: " · "))
  198. .font(.caption)
  199. .foregroundStyle(.secondary)
  200. } else {
  201. Text("Loading...")
  202. .font(.caption)
  203. .foregroundStyle(.tertiary)
  204. }
  205. Spacer()
  206. uploadControl
  207. }
  208. .padding(.horizontal, 16)
  209. .padding(.vertical, 8)
  210. .background(.bar)
  211. .task {
  212. do {
  213. stats = try await apiClient.fetchStats()
  214. } catch {
  215. statsError = true
  216. }
  217. }
  218. .onChange(of: uploadService.state) { _, newState in
  219. if case .success = newState {
  220. Task { stats = try? await apiClient.fetchStats() }
  221. }
  222. }
  223. .alert("Upload Failed", isPresented: $showUploadError) {
  224. Button("OK") { uploadService.dismiss() }
  225. } message: {
  226. if case .error(let msg) = uploadService.state {
  227. Text(msg)
  228. }
  229. }
  230. }
  231. @ViewBuilder
  232. private var uploadControl: some View {
  233. switch uploadService.state {
  234. case .idle:
  235. Button { chooseFile() } label: {
  236. Label("Upload", systemImage: "arrow.up.to.cloud")
  237. .font(.caption)
  238. }
  239. .buttonStyle(.bordered)
  240. .controlSize(.small)
  241. .help("Upload to Cloud")
  242. case .uploading(let fileName):
  243. HStack(spacing: 6) {
  244. ProgressView(value: uploadService.progress)
  245. .progressViewStyle(.linear)
  246. .frame(width: 60)
  247. Button { uploadService.cancel() } label: {
  248. Image(systemName: "xmark.circle.fill")
  249. .font(.system(size: 10))
  250. .foregroundStyle(.secondary)
  251. }
  252. .buttonStyle(.plain)
  253. .help("Cancel upload of \(fileName)")
  254. }
  255. case .success(let added, _):
  256. HStack(spacing: 4) {
  257. Image(systemName: "checkmark.circle.fill")
  258. .foregroundStyle(.green)
  259. .font(.caption)
  260. Text("\(added) added")
  261. .font(.caption)
  262. .foregroundStyle(.secondary)
  263. }
  264. .onAppear {
  265. Task {
  266. try? await Task.sleep(for: .seconds(3))
  267. uploadService.dismiss()
  268. }
  269. }
  270. case .error:
  271. Button { showUploadError = true } label: {
  272. Image(systemName: "exclamationmark.triangle.fill")
  273. .foregroundStyle(.red)
  274. }
  275. .buttonStyle(.plain)
  276. .help("Upload failed — click for details")
  277. .onAppear { showUploadError = true }
  278. }
  279. }
  280. private func chooseFile() {
  281. let panel = NSOpenPanel()
  282. panel.title = "Choose Audio File to Upload"
  283. panel.allowedContentTypes = UploadService.allowedTypes
  284. panel.allowsMultipleSelection = false
  285. panel.canChooseDirectories = false
  286. guard panel.runModal() == .OK, let url = panel.url else { return }
  287. uploadService.startUpload(fileURL: url, apiClient: apiClient)
  288. }
  289. }
  290. // MARK: - Filtered Albums View (artist/genre/year → albums)
  291. private struct FilteredAlbumsView: View {
  292. let apiClient: ChadMusicAPIClient
  293. let filter: CategoryFilter
  294. @Binding var navStack: [CloudNavDestination]
  295. @State private var albums: [ChadAlbum] = []
  296. @State private var isLoading = true
  297. @State private var error: String?
  298. @State private var albumSearchText: String = ""
  299. @Environment(\.modelContext) private var modelContext
  300. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  301. /// Client-side filtered albums based on search text.
  302. private var filteredAlbums: [ChadAlbum] {
  303. guard !albumSearchText.isEmpty else { return albums }
  304. let query = albumSearchText.lowercased()
  305. return albums.filter { $0.title.lowercased().contains(query) }
  306. }
  307. var body: some View {
  308. Group {
  309. if isLoading {
  310. ProgressView("Loading albums...")
  311. .frame(maxWidth: .infinity, maxHeight: .infinity)
  312. } else if let error {
  313. VStack(spacing: 8) {
  314. Image(systemName: "exclamationmark.triangle")
  315. .font(.title)
  316. .foregroundStyle(.secondary)
  317. Text(error)
  318. .foregroundStyle(.secondary)
  319. Button("Retry") { loadAlbums() }
  320. }
  321. .frame(maxWidth: .infinity, maxHeight: .infinity)
  322. } else if albums.isEmpty {
  323. Text("No albums found")
  324. .foregroundStyle(.secondary)
  325. .frame(maxWidth: .infinity, maxHeight: .infinity)
  326. } else {
  327. List {
  328. // Search field for filtering albums
  329. TextField("Search albums...", text: $albumSearchText)
  330. .textFieldStyle(.roundedBorder)
  331. .listRowSeparator(.hidden)
  332. .padding(.vertical, 4)
  333. // Header — draggable to add all albums by this artist/genre/etc.
  334. HStack {
  335. VStack(alignment: .leading, spacing: 2) {
  336. Text(filter.value)
  337. .font(.title2.bold())
  338. Text("\(albums.count) albums")
  339. .font(.caption)
  340. .foregroundStyle(.tertiary)
  341. }
  342. Spacer()
  343. Menu {
  344. ForEach(allPlaylists) { playlist in
  345. Button(playlist.name) {
  346. addAllAlbumsToPlaylist(playlist: playlist)
  347. }
  348. }
  349. } label: {
  350. Label("Add All", systemImage: "plus.circle")
  351. .font(.caption)
  352. }
  353. .menuStyle(.borderlessButton)
  354. .fixedSize()
  355. }
  356. .listRowSeparator(.hidden)
  357. .padding(.vertical, 4)
  358. .contextMenu {
  359. Menu("Add All to Playlist") {
  360. ForEach(allPlaylists) { playlist in
  361. Button(playlist.name) {
  362. addAllAlbumsToPlaylist(playlist: playlist)
  363. }
  364. }
  365. }
  366. }
  367. // Album rows
  368. if filteredAlbums.isEmpty && !albumSearchText.isEmpty {
  369. // No match — offer Soulseek search
  370. VStack(spacing: 12) {
  371. Text("\"\(albumSearchText)\" not in \(filter.value)'s library")
  372. .font(.callout)
  373. .foregroundStyle(.secondary)
  374. .multilineTextAlignment(.center)
  375. if SlskdAPIClient.shared.isConfigured {
  376. Button {
  377. navStack.append(.search(query: "\(filter.value) - \(albumSearchText)"))
  378. } label: {
  379. Label("Search Soulseek", systemImage: "magnifyingglass")
  380. }
  381. .buttonStyle(.bordered)
  382. .controlSize(.regular)
  383. }
  384. }
  385. .frame(maxWidth: .infinity)
  386. .padding(.vertical, 20)
  387. .listRowSeparator(.hidden)
  388. } else {
  389. ForEach(filteredAlbums) { album in
  390. Button {
  391. navStack.append(.album(album))
  392. } label: {
  393. HStack {
  394. VStack(alignment: .leading, spacing: 2) {
  395. Text(album.title)
  396. .lineLimit(1)
  397. if let artist = album.artist {
  398. Text(artist)
  399. .font(.caption)
  400. .foregroundStyle(.secondary)
  401. .lineLimit(1)
  402. }
  403. }
  404. Spacer()
  405. if let count = album.trackCount {
  406. Text("\(count)")
  407. .font(.caption)
  408. .foregroundStyle(.secondary)
  409. }
  410. Image(systemName: "chevron.right")
  411. .font(.caption2)
  412. .foregroundStyle(.tertiary)
  413. }
  414. }
  415. .buttonStyle(.plain)
  416. .contextMenu {
  417. Menu("Add Album to Playlist") {
  418. ForEach(allPlaylists) { playlist in
  419. Button(playlist.name) {
  420. addAlbumToPlaylist(album, playlist: playlist)
  421. }
  422. }
  423. }
  424. if SlskdAPIClient.shared.isConfigured {
  425. Divider()
  426. Button {
  427. let query = [album.artist, album.title].compactMap { $0 }.joined(separator: " - ")
  428. navStack.append(.search(query: query))
  429. } label: {
  430. Label("Find on Soulseek", systemImage: "magnifyingglass")
  431. }
  432. }
  433. }
  434. .draggable(album)
  435. }
  436. } // end else (filtered albums not empty)
  437. }
  438. .listStyle(.inset)
  439. }
  440. }
  441. .task { loadAlbums() }
  442. }
  443. private func loadAlbums() {
  444. isLoading = true
  445. error = nil
  446. Task {
  447. do {
  448. albums = try await apiClient.fetchAlbums(filteredBy: filter.category.rawValue, value: filter.value)
  449. } catch {
  450. self.error = error.localizedDescription
  451. }
  452. isLoading = false
  453. }
  454. }
  455. private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
  456. Task.detached {
  457. guard let tracks = try? await apiClient.fetchAlbumTracks(albumId: album.id) else { return }
  458. await MainActor.run {
  459. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.isCloud == true })
  460. let existing = (try? modelContext.fetch(descriptor)) ?? []
  461. let existingById = Dictionary(uniqueKeysWithValues: existing.compactMap { t in
  462. t.cloudTrackId.map { ($0, t) }
  463. })
  464. for chadTrack in tracks {
  465. let track = existingById[chadTrack.id] ?? {
  466. let t = Track.fromCloud(chadTrack)
  467. modelContext.insert(t)
  468. return t
  469. }()
  470. playlist.addTrack(track)
  471. }
  472. }
  473. }
  474. }
  475. private func addAllAlbumsToPlaylist(playlist: Playlist) {
  476. for album in albums {
  477. addAlbumToPlaylist(album, playlist: playlist)
  478. }
  479. }
  480. }
  481. // MARK: - Category Detail (list of albums/artists/etc.)
  482. private struct CategoryDetailView: View {
  483. let apiClient: ChadMusicAPIClient
  484. let category: ChadCategoryType
  485. @Binding var navStack: [CloudNavDestination]
  486. @State private var items: [ChadCategory] = []
  487. @State private var albums: [ChadAlbum] = []
  488. @State private var isLoading = true
  489. @State private var error: String?
  490. @State private var bulkAddingAlbum: String? // album ID being bulk-added
  491. @Environment(\.modelContext) private var modelContext
  492. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  493. /// Album category returns [ChadAlbum], all others return [ChadCategory].
  494. private var isAlbumCategory: Bool { category == .album }
  495. var body: some View {
  496. Group {
  497. if isLoading {
  498. ProgressView("Loading \(category.displayName)...")
  499. .frame(maxWidth: .infinity, maxHeight: .infinity)
  500. } else if let error {
  501. VStack(spacing: 8) {
  502. Image(systemName: "exclamationmark.triangle")
  503. .font(.title)
  504. .foregroundStyle(.secondary)
  505. Text(error)
  506. .foregroundStyle(.secondary)
  507. Button("Retry") { loadItems() }
  508. }
  509. .frame(maxWidth: .infinity, maxHeight: .infinity)
  510. } else if isAlbumCategory {
  511. List(albums) { album in
  512. Button {
  513. navStack.append(.album(album))
  514. } label: {
  515. HStack {
  516. VStack(alignment: .leading, spacing: 2) {
  517. Text(album.title)
  518. .lineLimit(1)
  519. if let artist = album.artist {
  520. Text(artist)
  521. .font(.caption)
  522. .foregroundStyle(.secondary)
  523. .lineLimit(1)
  524. }
  525. }
  526. Spacer()
  527. if bulkAddingAlbum == album.id {
  528. ProgressView()
  529. .controlSize(.small)
  530. } else if let count = album.trackCount {
  531. Text("\(count)")
  532. .font(.caption)
  533. .foregroundStyle(.secondary)
  534. }
  535. Image(systemName: "chevron.right")
  536. .font(.caption2)
  537. .foregroundStyle(.tertiary)
  538. }
  539. }
  540. .buttonStyle(.plain)
  541. .contextMenu {
  542. Menu("Add Album to Playlist") {
  543. ForEach(allPlaylists) { playlist in
  544. Button(playlist.name) {
  545. addAlbumToPlaylist(album, playlist: playlist)
  546. }
  547. }
  548. }
  549. if SlskdAPIClient.shared.isConfigured {
  550. Divider()
  551. Button {
  552. let query = [album.artist, album.title].compactMap { $0 }.joined(separator: " - ")
  553. navStack.append(.search(query: query))
  554. } label: {
  555. Label("Find on Soulseek", systemImage: "magnifyingglass")
  556. }
  557. }
  558. }
  559. .draggable(album)
  560. }
  561. .listStyle(.inset)
  562. } else {
  563. List(items) { item in
  564. Button {
  565. navStack.append(.filter(CategoryFilter(category: category, value: item.name)))
  566. } label: {
  567. HStack {
  568. categoryRow(item)
  569. Image(systemName: "chevron.right")
  570. .font(.caption2)
  571. .foregroundStyle(.tertiary)
  572. }
  573. }
  574. .buttonStyle(.plain)
  575. }
  576. .listStyle(.inset)
  577. }
  578. }
  579. .task { loadItems() }
  580. }
  581. private func categoryRow(_ item: ChadCategory) -> some View {
  582. HStack {
  583. Text(item.name)
  584. Spacer()
  585. if let count = item.count {
  586. Text("\(count)")
  587. .font(.caption)
  588. .foregroundStyle(.secondary)
  589. }
  590. }
  591. }
  592. private func loadItems() {
  593. isLoading = true
  594. error = nil
  595. Task {
  596. do {
  597. if isAlbumCategory {
  598. albums = try await apiClient.fetchAlbums()
  599. } else {
  600. items = try await apiClient.fetchCategory(category)
  601. }
  602. } catch {
  603. self.error = error.localizedDescription
  604. }
  605. isLoading = false
  606. }
  607. }
  608. private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
  609. bulkAddingAlbum = album.id
  610. Task.detached {
  611. let chadTracks: [ChadTrack]
  612. do {
  613. chadTracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
  614. } catch {
  615. print("CloudBrowser: Failed to fetch album tracks: \(error)")
  616. await MainActor.run { bulkAddingAlbum = nil }
  617. return
  618. }
  619. await MainActor.run {
  620. bulkInsertCloudTracks(chadTracks, into: playlist)
  621. bulkAddingAlbum = nil
  622. }
  623. }
  624. }
  625. private func bulkInsertCloudTracks(_ chadTracks: [ChadTrack], into playlist: Playlist) {
  626. // Batch dedup: fetch all existing cloud tracks in one query
  627. let ids = chadTracks.map(\.id)
  628. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { track in
  629. track.isCloud == true
  630. })
  631. let existingTracks = (try? modelContext.fetch(descriptor)) ?? []
  632. let existingById = Dictionary(uniqueKeysWithValues: existingTracks.compactMap { t in
  633. t.cloudTrackId.map { ($0, t) }
  634. })
  635. for chadTrack in chadTracks {
  636. let track = existingById[chadTrack.id] ?? {
  637. let newTrack = Track.fromCloud(chadTrack)
  638. modelContext.insert(newTrack)
  639. return newTrack
  640. }()
  641. playlist.addTrack(track)
  642. }
  643. }
  644. }
  645. // MARK: - Album Detail (track list with play buttons)
  646. private struct AlbumDetailView: View {
  647. let apiClient: ChadMusicAPIClient
  648. let album: ChadAlbum
  649. @Binding var navStack: [CloudNavDestination]
  650. @Environment(PlayerViewModel.self) private var playerVM
  651. @Environment(\.modelContext) private var modelContext
  652. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  653. @State private var tracks: [ChadTrack] = []
  654. @State private var isLoading = true
  655. @State private var error: String?
  656. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  657. @State private var downloadManager = DownloadManager.shared
  658. /// Persisted Track objects for cloud tracks (used for download state tracking).
  659. @State private var persistedTracks: [String: Track] = [:]
  660. var body: some View {
  661. Group {
  662. if isLoading {
  663. ProgressView("Loading tracks...")
  664. .frame(maxWidth: .infinity, maxHeight: .infinity)
  665. } else if let error {
  666. VStack(spacing: 8) {
  667. Image(systemName: "exclamationmark.triangle")
  668. .font(.title)
  669. .foregroundStyle(.secondary)
  670. Text(error)
  671. .foregroundStyle(.secondary)
  672. Button("Retry") { loadTracks() }
  673. }
  674. .frame(maxWidth: .infinity, maxHeight: .infinity)
  675. } else {
  676. List {
  677. // Album header — draggable to add whole album to playlist
  678. VStack(alignment: .leading, spacing: 4) {
  679. Text(album.title)
  680. .font(.title2.bold())
  681. if let artist = album.artist {
  682. Text(artist)
  683. .font(.title3)
  684. .foregroundStyle(.secondary)
  685. }
  686. HStack {
  687. Text("\(tracks.count) tracks")
  688. .font(.caption)
  689. .foregroundStyle(.tertiary)
  690. Spacer()
  691. AlbumDownloadButton(
  692. tracks: Array(persistedTracks.values),
  693. apiClient: apiClient
  694. )
  695. Menu {
  696. ForEach(allPlaylists) { playlist in
  697. Button(playlist.name) {
  698. addAllToPlaylist(playlist: playlist)
  699. }
  700. }
  701. } label: {
  702. Label("Add All", systemImage: "plus.circle")
  703. .font(.caption)
  704. }
  705. .menuStyle(.borderlessButton)
  706. .fixedSize()
  707. }
  708. }
  709. .listRowSeparator(.hidden)
  710. .padding(.vertical, 8)
  711. .draggable(album)
  712. .contextMenu {
  713. if playbackMode == "queue" {
  714. Button {
  715. for track in tracks {
  716. playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
  717. }
  718. } label: {
  719. Label("Play Album Next", systemImage: "text.line.first.and.arrowtriangle.forward")
  720. }
  721. Button {
  722. for track in tracks {
  723. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  724. }
  725. } label: {
  726. Label("Add Album to Queue", systemImage: "text.append")
  727. }
  728. Divider()
  729. }
  730. Menu("Add Album to Playlist") {
  731. ForEach(allPlaylists) { playlist in
  732. Button(playlist.name) {
  733. addAllToPlaylist(playlist: playlist)
  734. }
  735. }
  736. }
  737. Divider()
  738. Button {
  739. let persisted = tracks.map { ensurePersistedTrack(for: $0) }
  740. downloadManager.downloadBatch(tracks: persisted, apiClient: apiClient)
  741. } label: {
  742. Label("Download All", systemImage: "arrow.down.circle")
  743. }
  744. }
  745. // Track rows
  746. ForEach(tracks) { track in
  747. CloudTrackRow(
  748. track: track,
  749. isPlaying: playerVM.isCloudPlayback && (
  750. playerVM.currentCloudTrack?.id == track.id ||
  751. playerVM.currentTrack?.cloudTrackId == track.id
  752. ),
  753. persistedTrack: persistedTracks[track.id],
  754. onDownload: {
  755. let persisted = ensurePersistedTrack(for: track)
  756. downloadManager.download(track: persisted, apiClient: apiClient)
  757. }
  758. )
  759. .contentShape(Rectangle())
  760. .onTapGesture {
  761. playCloudTrack(track)
  762. }
  763. .onDrag {
  764. let data = try? JSONEncoder().encode(track)
  765. let provider = NSItemProvider()
  766. if let data {
  767. provider.registerDataRepresentation(
  768. forTypeIdentifier: "com.mixboard.chad-track",
  769. visibility: .all
  770. ) { completion in
  771. completion(data, nil)
  772. return nil
  773. }
  774. }
  775. return provider
  776. }
  777. .contextMenu {
  778. Button {
  779. playCloudTrack(track)
  780. } label: {
  781. Label("Play", systemImage: "play")
  782. }
  783. Divider()
  784. if playbackMode == "queue" {
  785. Button {
  786. playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
  787. } label: {
  788. Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
  789. }
  790. Button {
  791. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  792. } label: {
  793. Label("Add to Queue", systemImage: "text.append")
  794. }
  795. Divider()
  796. }
  797. // Download actions
  798. if let persisted = persistedTracks[track.id] {
  799. downloadContextMenuItems(for: persisted)
  800. Divider()
  801. } else {
  802. Button {
  803. let persisted = ensurePersistedTrack(for: track)
  804. downloadManager.download(track: persisted, apiClient: apiClient)
  805. } label: {
  806. Label("Download", systemImage: "arrow.down.circle")
  807. }
  808. Divider()
  809. }
  810. Menu("Add to Playlist") {
  811. ForEach(allPlaylists) { playlist in
  812. Button(playlist.name) {
  813. addToPlaylist(track, playlist: playlist)
  814. }
  815. }
  816. }
  817. if SlskdAPIClient.shared.isConfigured {
  818. Divider()
  819. Button {
  820. let query = [track.artist, track.title].compactMap { $0 }.joined(separator: " - ")
  821. navStack.append(.search(query: query))
  822. } label: {
  823. Label("Find on Soulseek", systemImage: "magnifyingglass")
  824. }
  825. }
  826. }
  827. }
  828. }
  829. .listStyle(.inset)
  830. }
  831. }
  832. .task {
  833. loadTracks()
  834. loadPersistedTracks()
  835. }
  836. }
  837. private func loadTracks() {
  838. isLoading = true
  839. error = nil
  840. Task {
  841. do {
  842. tracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
  843. loadPersistedTracks()
  844. } catch {
  845. self.error = error.localizedDescription
  846. }
  847. isLoading = false
  848. }
  849. }
  850. private func loadPersistedTracks() {
  851. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.isCloud == true })
  852. guard let existing = try? modelContext.fetch(descriptor) else { return }
  853. var map: [String: Track] = [:]
  854. for t in existing {
  855. if let id = t.cloudTrackId {
  856. map[id] = t
  857. }
  858. }
  859. persistedTracks = map
  860. }
  861. /// Ensure a ChadTrack has a persisted SwiftData Track. Returns the persisted track.
  862. private func ensurePersistedTrack(for chadTrack: ChadTrack) -> Track {
  863. if let existing = persistedTracks[chadTrack.id] {
  864. return existing
  865. }
  866. let track = Track.fromCloud(chadTrack)
  867. modelContext.insert(track)
  868. persistedTracks[chadTrack.id] = track
  869. return track
  870. }
  871. @ViewBuilder
  872. private func downloadContextMenuItems(for track: Track) -> some View {
  873. switch track.downloadState {
  874. case .none:
  875. Button {
  876. downloadManager.download(track: track, apiClient: apiClient)
  877. } label: {
  878. Label("Download", systemImage: "arrow.down.circle")
  879. }
  880. case .downloading:
  881. Button {
  882. downloadManager.cancel(track: track)
  883. } label: {
  884. Label("Cancel Download", systemImage: "stop.circle")
  885. }
  886. case .downloaded:
  887. Button(role: .destructive) {
  888. downloadManager.removeDownload(track: track)
  889. } label: {
  890. Label("Remove Download", systemImage: "trash")
  891. }
  892. case .error:
  893. Button {
  894. downloadManager.download(track: track, apiClient: apiClient)
  895. } label: {
  896. Label("Retry Download", systemImage: "arrow.clockwise")
  897. }
  898. }
  899. }
  900. private func playCloudTrack(_ track: ChadTrack) {
  901. guard let url = apiClient.streamURL(for: track.url) else {
  902. print("CloudBrowser: Failed to build stream URL for \(track.url)")
  903. return
  904. }
  905. playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: apiClient.authHeaders)
  906. }
  907. private func addToPlaylist(_ chadTrack: ChadTrack, playlist: Playlist) {
  908. let cloudId = chadTrack.id
  909. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.cloudTrackId == cloudId })
  910. let existing = try? modelContext.fetch(descriptor).first
  911. let track = existing ?? Track.fromCloud(chadTrack)
  912. if existing == nil {
  913. modelContext.insert(track)
  914. }
  915. playlist.addTrack(track)
  916. }
  917. private func addAllToPlaylist(playlist: Playlist) {
  918. for chadTrack in tracks {
  919. addToPlaylist(chadTrack, playlist: playlist)
  920. }
  921. }
  922. }
  923. // MARK: - Cloud Track Row
  924. private struct CloudTrackRow: View {
  925. let track: ChadTrack
  926. let isPlaying: Bool
  927. var persistedTrack: Track?
  928. var onDownload: (() -> Void)? = nil
  929. var body: some View {
  930. HStack(spacing: 12) {
  931. // Track number or playing indicator
  932. Group {
  933. if isPlaying {
  934. Image(systemName: "speaker.wave.2.fill")
  935. .foregroundStyle(Color.accentColor)
  936. } else if let num = track.trackNumber {
  937. Text("\(num)")
  938. .foregroundStyle(.secondary)
  939. } else {
  940. Text("—")
  941. .foregroundStyle(.tertiary)
  942. }
  943. }
  944. .font(.system(size: 12, design: .monospaced))
  945. .frame(width: 28, alignment: .trailing)
  946. // Title + artist
  947. VStack(alignment: .leading, spacing: 1) {
  948. Text(track.title)
  949. .font(.system(size: 13))
  950. .foregroundStyle(isPlaying ? Color.accentColor : .primary)
  951. .lineLimit(1)
  952. if let artist = track.artist {
  953. Text(artist)
  954. .font(.system(size: 11))
  955. .foregroundStyle(.secondary)
  956. .lineLimit(1)
  957. }
  958. }
  959. Spacer()
  960. // Upload / download indicator for cloud tracks
  961. if let persistedTrack {
  962. DownloadIndicator(track: persistedTrack)
  963. } else {
  964. Button {
  965. onDownload?()
  966. } label: {
  967. Image(systemName: "arrow.down.circle")
  968. .font(.system(size: 14))
  969. .foregroundStyle(.tertiary)
  970. .frame(width: 20, height: 20)
  971. .contentShape(Rectangle())
  972. }
  973. .buttonStyle(.plain)
  974. .help("Download for offline playback")
  975. }
  976. // Duration
  977. Text(track.formattedDuration)
  978. .font(.system(size: 12, design: .monospaced))
  979. .foregroundStyle(.secondary)
  980. .frame(width: 40, alignment: .trailing)
  981. }
  982. .padding(.vertical, 2)
  983. }
  984. }
  985. // MARK: - Soulseek Status Banner
  986. /// Persistent overlay at the bottom of CloudBrowserView showing Soulseek pipeline status.
  987. private struct SoulseekStatusBanner: View {
  988. let orchestrator: SoulseekOrchestrator
  989. var body: some View {
  990. let state = orchestrator.state
  991. if state != .idle {
  992. HStack(spacing: 10) {
  993. // Progress indicator
  994. if state.isActive {
  995. if case .downloading(let progress) = state {
  996. ProgressView(value: progress)
  997. .progressViewStyle(.circular)
  998. .controlSize(.small)
  999. } else {
  1000. ProgressView()
  1001. .controlSize(.small)
  1002. }
  1003. } else if case .complete = state {
  1004. Image(systemName: "checkmark.circle.fill")
  1005. .foregroundStyle(.green)
  1006. } else if case .failed = state {
  1007. Image(systemName: "exclamationmark.triangle.fill")
  1008. .foregroundStyle(.red)
  1009. }
  1010. // Status text
  1011. Text(state.statusText)
  1012. .font(.system(size: 12))
  1013. .foregroundStyle(state.isActive ? .primary : .secondary)
  1014. .lineLimit(1)
  1015. Spacer()
  1016. // Action button
  1017. if state.isActive {
  1018. Button("Cancel") {
  1019. orchestrator.cancel()
  1020. }
  1021. .buttonStyle(.bordered)
  1022. .controlSize(.small)
  1023. } else {
  1024. Button("Dismiss") {
  1025. orchestrator.dismiss()
  1026. }
  1027. .buttonStyle(.bordered)
  1028. .controlSize(.small)
  1029. }
  1030. }
  1031. .padding(.horizontal, 12)
  1032. .padding(.vertical, 8)
  1033. .background(.ultraThinMaterial)
  1034. .transition(.move(edge: .bottom).combined(with: .opacity))
  1035. .animation(.easeInOut(duration: 0.3), value: state != .idle)
  1036. }
  1037. }
  1038. }