CloudBrowserView.swift 36 KB

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