CloudBrowserView.swift 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103
  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. }
  425. .draggable(album)
  426. }
  427. } // end else (filtered albums not empty)
  428. }
  429. .listStyle(.inset)
  430. }
  431. }
  432. .task { loadAlbums() }
  433. }
  434. private func loadAlbums() {
  435. isLoading = true
  436. error = nil
  437. Task {
  438. do {
  439. albums = try await apiClient.fetchAlbums(filteredBy: filter.category.rawValue, value: filter.value)
  440. } catch {
  441. self.error = error.localizedDescription
  442. }
  443. isLoading = false
  444. }
  445. }
  446. private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
  447. Task.detached {
  448. guard let tracks = try? await apiClient.fetchAlbumTracks(albumId: album.id) else { return }
  449. await MainActor.run {
  450. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.isCloud == true })
  451. let existing = (try? modelContext.fetch(descriptor)) ?? []
  452. let existingById = Dictionary(uniqueKeysWithValues: existing.compactMap { t in
  453. t.cloudTrackId.map { ($0, t) }
  454. })
  455. for chadTrack in tracks {
  456. let track = existingById[chadTrack.id] ?? {
  457. let t = Track.fromCloud(chadTrack)
  458. modelContext.insert(t)
  459. return t
  460. }()
  461. playlist.addTrack(track)
  462. }
  463. }
  464. }
  465. }
  466. private func addAllAlbumsToPlaylist(playlist: Playlist) {
  467. for album in albums {
  468. addAlbumToPlaylist(album, playlist: playlist)
  469. }
  470. }
  471. }
  472. // MARK: - Category Detail (list of albums/artists/etc.)
  473. private struct CategoryDetailView: View {
  474. let apiClient: ChadMusicAPIClient
  475. let category: ChadCategoryType
  476. @Binding var navStack: [CloudNavDestination]
  477. @State private var items: [ChadCategory] = []
  478. @State private var albums: [ChadAlbum] = []
  479. @State private var isLoading = true
  480. @State private var error: String?
  481. @State private var bulkAddingAlbum: String? // album ID being bulk-added
  482. @Environment(\.modelContext) private var modelContext
  483. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  484. /// Album category returns [ChadAlbum], all others return [ChadCategory].
  485. private var isAlbumCategory: Bool { category == .album }
  486. var body: some View {
  487. Group {
  488. if isLoading {
  489. ProgressView("Loading \(category.displayName)...")
  490. .frame(maxWidth: .infinity, maxHeight: .infinity)
  491. } else if let error {
  492. VStack(spacing: 8) {
  493. Image(systemName: "exclamationmark.triangle")
  494. .font(.title)
  495. .foregroundStyle(.secondary)
  496. Text(error)
  497. .foregroundStyle(.secondary)
  498. Button("Retry") { loadItems() }
  499. }
  500. .frame(maxWidth: .infinity, maxHeight: .infinity)
  501. } else if isAlbumCategory {
  502. List(albums) { album in
  503. Button {
  504. navStack.append(.album(album))
  505. } label: {
  506. HStack {
  507. VStack(alignment: .leading, spacing: 2) {
  508. Text(album.title)
  509. .lineLimit(1)
  510. if let artist = album.artist {
  511. Text(artist)
  512. .font(.caption)
  513. .foregroundStyle(.secondary)
  514. .lineLimit(1)
  515. }
  516. }
  517. Spacer()
  518. if bulkAddingAlbum == album.id {
  519. ProgressView()
  520. .controlSize(.small)
  521. } else if let count = album.trackCount {
  522. Text("\(count)")
  523. .font(.caption)
  524. .foregroundStyle(.secondary)
  525. }
  526. Image(systemName: "chevron.right")
  527. .font(.caption2)
  528. .foregroundStyle(.tertiary)
  529. }
  530. }
  531. .buttonStyle(.plain)
  532. .contextMenu {
  533. Menu("Add Album to Playlist") {
  534. ForEach(allPlaylists) { playlist in
  535. Button(playlist.name) {
  536. addAlbumToPlaylist(album, playlist: playlist)
  537. }
  538. }
  539. }
  540. }
  541. .draggable(album)
  542. }
  543. .listStyle(.inset)
  544. } else {
  545. List(items) { item in
  546. Button {
  547. navStack.append(.filter(CategoryFilter(category: category, value: item.name)))
  548. } label: {
  549. HStack {
  550. categoryRow(item)
  551. Image(systemName: "chevron.right")
  552. .font(.caption2)
  553. .foregroundStyle(.tertiary)
  554. }
  555. }
  556. .buttonStyle(.plain)
  557. }
  558. .listStyle(.inset)
  559. }
  560. }
  561. .task { loadItems() }
  562. }
  563. private func categoryRow(_ item: ChadCategory) -> some View {
  564. HStack {
  565. Text(item.name)
  566. Spacer()
  567. if let count = item.count {
  568. Text("\(count)")
  569. .font(.caption)
  570. .foregroundStyle(.secondary)
  571. }
  572. }
  573. }
  574. private func loadItems() {
  575. isLoading = true
  576. error = nil
  577. Task {
  578. do {
  579. if isAlbumCategory {
  580. albums = try await apiClient.fetchAlbums()
  581. } else {
  582. items = try await apiClient.fetchCategory(category)
  583. }
  584. } catch {
  585. self.error = error.localizedDescription
  586. }
  587. isLoading = false
  588. }
  589. }
  590. private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
  591. bulkAddingAlbum = album.id
  592. Task.detached {
  593. let chadTracks: [ChadTrack]
  594. do {
  595. chadTracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
  596. } catch {
  597. print("CloudBrowser: Failed to fetch album tracks: \(error)")
  598. await MainActor.run { bulkAddingAlbum = nil }
  599. return
  600. }
  601. await MainActor.run {
  602. bulkInsertCloudTracks(chadTracks, into: playlist)
  603. bulkAddingAlbum = nil
  604. }
  605. }
  606. }
  607. private func bulkInsertCloudTracks(_ chadTracks: [ChadTrack], into playlist: Playlist) {
  608. // Batch dedup: fetch all existing cloud tracks in one query
  609. let ids = chadTracks.map(\.id)
  610. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { track in
  611. track.isCloud == true
  612. })
  613. let existingTracks = (try? modelContext.fetch(descriptor)) ?? []
  614. let existingById = Dictionary(uniqueKeysWithValues: existingTracks.compactMap { t in
  615. t.cloudTrackId.map { ($0, t) }
  616. })
  617. for chadTrack in chadTracks {
  618. let track = existingById[chadTrack.id] ?? {
  619. let newTrack = Track.fromCloud(chadTrack)
  620. modelContext.insert(newTrack)
  621. return newTrack
  622. }()
  623. playlist.addTrack(track)
  624. }
  625. }
  626. }
  627. // MARK: - Album Detail (track list with play buttons)
  628. private struct AlbumDetailView: View {
  629. let apiClient: ChadMusicAPIClient
  630. let album: ChadAlbum
  631. @Binding var navStack: [CloudNavDestination]
  632. @Environment(PlayerViewModel.self) private var playerVM
  633. @Environment(\.modelContext) private var modelContext
  634. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  635. @State private var tracks: [ChadTrack] = []
  636. @State private var isLoading = true
  637. @State private var error: String?
  638. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  639. @State private var downloadManager = DownloadManager.shared
  640. /// Persisted Track objects for cloud tracks (used for download state tracking).
  641. @State private var persistedTracks: [String: Track] = [:]
  642. var body: some View {
  643. Group {
  644. if isLoading {
  645. ProgressView("Loading tracks...")
  646. .frame(maxWidth: .infinity, maxHeight: .infinity)
  647. } else if let error {
  648. VStack(spacing: 8) {
  649. Image(systemName: "exclamationmark.triangle")
  650. .font(.title)
  651. .foregroundStyle(.secondary)
  652. Text(error)
  653. .foregroundStyle(.secondary)
  654. Button("Retry") { loadTracks() }
  655. }
  656. .frame(maxWidth: .infinity, maxHeight: .infinity)
  657. } else {
  658. List {
  659. // Album header — draggable to add whole album to playlist
  660. VStack(alignment: .leading, spacing: 4) {
  661. Text(album.title)
  662. .font(.title2.bold())
  663. if let artist = album.artist {
  664. Text(artist)
  665. .font(.title3)
  666. .foregroundStyle(.secondary)
  667. }
  668. HStack {
  669. Text("\(tracks.count) tracks")
  670. .font(.caption)
  671. .foregroundStyle(.tertiary)
  672. Spacer()
  673. AlbumDownloadButton(
  674. tracks: Array(persistedTracks.values),
  675. apiClient: apiClient
  676. )
  677. Menu {
  678. ForEach(allPlaylists) { playlist in
  679. Button(playlist.name) {
  680. addAllToPlaylist(playlist: playlist)
  681. }
  682. }
  683. } label: {
  684. Label("Add All", systemImage: "plus.circle")
  685. .font(.caption)
  686. }
  687. .menuStyle(.borderlessButton)
  688. .fixedSize()
  689. }
  690. }
  691. .listRowSeparator(.hidden)
  692. .padding(.vertical, 8)
  693. .draggable(album)
  694. .contextMenu {
  695. if playbackMode == "queue" {
  696. Button {
  697. for track in tracks {
  698. playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
  699. }
  700. } label: {
  701. Label("Play Album Next", systemImage: "text.line.first.and.arrowtriangle.forward")
  702. }
  703. Button {
  704. for track in tracks {
  705. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  706. }
  707. } label: {
  708. Label("Add Album to Queue", systemImage: "text.append")
  709. }
  710. Divider()
  711. }
  712. Menu("Add Album to Playlist") {
  713. ForEach(allPlaylists) { playlist in
  714. Button(playlist.name) {
  715. addAllToPlaylist(playlist: playlist)
  716. }
  717. }
  718. }
  719. Divider()
  720. Button {
  721. let persisted = tracks.map { ensurePersistedTrack(for: $0) }
  722. downloadManager.downloadBatch(tracks: persisted, apiClient: apiClient)
  723. } label: {
  724. Label("Download All", systemImage: "arrow.down.circle")
  725. }
  726. }
  727. // Track rows
  728. ForEach(tracks) { track in
  729. CloudTrackRow(
  730. track: track,
  731. isPlaying: playerVM.isCloudPlayback && (
  732. playerVM.currentCloudTrack?.id == track.id ||
  733. playerVM.currentTrack?.cloudTrackId == track.id
  734. ),
  735. persistedTrack: persistedTracks[track.id],
  736. onDownload: {
  737. let persisted = ensurePersistedTrack(for: track)
  738. downloadManager.download(track: persisted, apiClient: apiClient)
  739. }
  740. )
  741. .contentShape(Rectangle())
  742. .onTapGesture {
  743. playCloudTrack(track)
  744. }
  745. .onDrag {
  746. let data = try? JSONEncoder().encode(track)
  747. let provider = NSItemProvider()
  748. if let data {
  749. provider.registerDataRepresentation(
  750. forTypeIdentifier: "com.mixboard.chad-track",
  751. visibility: .all
  752. ) { completion in
  753. completion(data, nil)
  754. return nil
  755. }
  756. }
  757. return provider
  758. }
  759. .contextMenu {
  760. Button {
  761. playCloudTrack(track)
  762. } label: {
  763. Label("Play", systemImage: "play")
  764. }
  765. Divider()
  766. if playbackMode == "queue" {
  767. Button {
  768. playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
  769. } label: {
  770. Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
  771. }
  772. Button {
  773. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  774. } label: {
  775. Label("Add to Queue", systemImage: "text.append")
  776. }
  777. Divider()
  778. }
  779. // Download actions
  780. if let persisted = persistedTracks[track.id] {
  781. downloadContextMenuItems(for: persisted)
  782. Divider()
  783. } else {
  784. Button {
  785. let persisted = ensurePersistedTrack(for: track)
  786. downloadManager.download(track: persisted, apiClient: apiClient)
  787. } label: {
  788. Label("Download", systemImage: "arrow.down.circle")
  789. }
  790. Divider()
  791. }
  792. Menu("Add to Playlist") {
  793. ForEach(allPlaylists) { playlist in
  794. Button(playlist.name) {
  795. addToPlaylist(track, playlist: playlist)
  796. }
  797. }
  798. }
  799. }
  800. }
  801. }
  802. .listStyle(.inset)
  803. }
  804. }
  805. .task {
  806. loadTracks()
  807. loadPersistedTracks()
  808. }
  809. }
  810. private func loadTracks() {
  811. isLoading = true
  812. error = nil
  813. Task {
  814. do {
  815. tracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
  816. loadPersistedTracks()
  817. } catch {
  818. self.error = error.localizedDescription
  819. }
  820. isLoading = false
  821. }
  822. }
  823. private func loadPersistedTracks() {
  824. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.isCloud == true })
  825. guard let existing = try? modelContext.fetch(descriptor) else { return }
  826. var map: [String: Track] = [:]
  827. for t in existing {
  828. if let id = t.cloudTrackId {
  829. map[id] = t
  830. }
  831. }
  832. persistedTracks = map
  833. }
  834. /// Ensure a ChadTrack has a persisted SwiftData Track. Returns the persisted track.
  835. private func ensurePersistedTrack(for chadTrack: ChadTrack) -> Track {
  836. if let existing = persistedTracks[chadTrack.id] {
  837. return existing
  838. }
  839. let track = Track.fromCloud(chadTrack)
  840. modelContext.insert(track)
  841. persistedTracks[chadTrack.id] = track
  842. return track
  843. }
  844. @ViewBuilder
  845. private func downloadContextMenuItems(for track: Track) -> some View {
  846. switch track.downloadState {
  847. case .none:
  848. Button {
  849. downloadManager.download(track: track, apiClient: apiClient)
  850. } label: {
  851. Label("Download", systemImage: "arrow.down.circle")
  852. }
  853. case .downloading:
  854. Button {
  855. downloadManager.cancel(track: track)
  856. } label: {
  857. Label("Cancel Download", systemImage: "stop.circle")
  858. }
  859. case .downloaded:
  860. Button(role: .destructive) {
  861. downloadManager.removeDownload(track: track)
  862. } label: {
  863. Label("Remove Download", systemImage: "trash")
  864. }
  865. case .error:
  866. Button {
  867. downloadManager.download(track: track, apiClient: apiClient)
  868. } label: {
  869. Label("Retry Download", systemImage: "arrow.clockwise")
  870. }
  871. }
  872. }
  873. private func playCloudTrack(_ track: ChadTrack) {
  874. guard let url = apiClient.streamURL(for: track.url) else {
  875. print("CloudBrowser: Failed to build stream URL for \(track.url)")
  876. return
  877. }
  878. playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: apiClient.authHeaders)
  879. }
  880. private func addToPlaylist(_ chadTrack: ChadTrack, playlist: Playlist) {
  881. let cloudId = chadTrack.id
  882. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.cloudTrackId == cloudId })
  883. let existing = try? modelContext.fetch(descriptor).first
  884. let track = existing ?? Track.fromCloud(chadTrack)
  885. if existing == nil {
  886. modelContext.insert(track)
  887. }
  888. playlist.addTrack(track)
  889. }
  890. private func addAllToPlaylist(playlist: Playlist) {
  891. for chadTrack in tracks {
  892. addToPlaylist(chadTrack, playlist: playlist)
  893. }
  894. }
  895. }
  896. // MARK: - Cloud Track Row
  897. private struct CloudTrackRow: View {
  898. let track: ChadTrack
  899. let isPlaying: Bool
  900. var persistedTrack: Track?
  901. var onDownload: (() -> Void)? = nil
  902. var body: some View {
  903. HStack(spacing: 12) {
  904. // Track number or playing indicator
  905. Group {
  906. if isPlaying {
  907. Image(systemName: "speaker.wave.2.fill")
  908. .foregroundStyle(Color.accentColor)
  909. } else if let num = track.trackNumber {
  910. Text("\(num)")
  911. .foregroundStyle(.secondary)
  912. } else {
  913. Text("—")
  914. .foregroundStyle(.tertiary)
  915. }
  916. }
  917. .font(.system(size: 12, design: .monospaced))
  918. .frame(width: 28, alignment: .trailing)
  919. // Title + artist
  920. VStack(alignment: .leading, spacing: 1) {
  921. Text(track.title)
  922. .font(.system(size: 13))
  923. .foregroundStyle(isPlaying ? Color.accentColor : .primary)
  924. .lineLimit(1)
  925. if let artist = track.artist {
  926. Text(artist)
  927. .font(.system(size: 11))
  928. .foregroundStyle(.secondary)
  929. .lineLimit(1)
  930. }
  931. }
  932. Spacer()
  933. // Upload / download indicator for cloud tracks
  934. if let persistedTrack {
  935. DownloadIndicator(track: persistedTrack)
  936. } else {
  937. Button {
  938. onDownload?()
  939. } label: {
  940. Image(systemName: "arrow.down.circle")
  941. .font(.system(size: 14))
  942. .foregroundStyle(.tertiary)
  943. .frame(width: 20, height: 20)
  944. .contentShape(Rectangle())
  945. }
  946. .buttonStyle(.plain)
  947. .help("Download for offline playback")
  948. }
  949. // Duration
  950. Text(track.formattedDuration)
  951. .font(.system(size: 12, design: .monospaced))
  952. .foregroundStyle(.secondary)
  953. .frame(width: 40, alignment: .trailing)
  954. }
  955. .padding(.vertical, 2)
  956. }
  957. }
  958. // MARK: - Soulseek Status Banner
  959. /// Persistent overlay at the bottom of CloudBrowserView showing Soulseek pipeline status.
  960. private struct SoulseekStatusBanner: View {
  961. let orchestrator: SoulseekOrchestrator
  962. var body: some View {
  963. let state = orchestrator.state
  964. if state != .idle {
  965. HStack(spacing: 10) {
  966. // Progress indicator
  967. if state.isActive {
  968. if case .downloading(let progress) = state {
  969. ProgressView(value: progress)
  970. .progressViewStyle(.circular)
  971. .controlSize(.small)
  972. } else {
  973. ProgressView()
  974. .controlSize(.small)
  975. }
  976. } else if case .complete = state {
  977. Image(systemName: "checkmark.circle.fill")
  978. .foregroundStyle(.green)
  979. } else if case .failed = state {
  980. Image(systemName: "exclamationmark.triangle.fill")
  981. .foregroundStyle(.red)
  982. }
  983. // Status text
  984. Text(state.statusText)
  985. .font(.system(size: 12))
  986. .foregroundStyle(state.isActive ? .primary : .secondary)
  987. .lineLimit(1)
  988. Spacer()
  989. // Action button
  990. if state.isActive {
  991. Button("Cancel") {
  992. orchestrator.cancel()
  993. }
  994. .buttonStyle(.bordered)
  995. .controlSize(.small)
  996. } else {
  997. Button("Dismiss") {
  998. orchestrator.dismiss()
  999. }
  1000. .buttonStyle(.bordered)
  1001. .controlSize(.small)
  1002. }
  1003. }
  1004. .padding(.horizontal, 12)
  1005. .padding(.vertical, 8)
  1006. .background(.ultraThinMaterial)
  1007. .transition(.move(edge: .bottom).combined(with: .opacity))
  1008. .animation(.easeInOut(duration: 0.3), value: state != .idle)
  1009. }
  1010. }
  1011. }