CloudBrowserView.swift 36 KB

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