CloudBrowserView.swift 42 KB

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