CloudBrowserView.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import SwiftData
  2. import SwiftUI
  3. /// Cloud library browser — navigate categories → albums → tracks from Chad Music server.
  4. struct CloudBrowserView: View {
  5. @Environment(PlayerViewModel.self) private var playerVM
  6. @EnvironmentObject private var theme: AppTheme
  7. @State private var apiClient = ChadMusicAPIClient.shared
  8. var body: some View {
  9. if !apiClient.isConfigured {
  10. CloudNotConfiguredView()
  11. } else {
  12. CategoryListView(apiClient: apiClient)
  13. }
  14. }
  15. }
  16. // MARK: - Not Configured Prompt
  17. private struct CloudNotConfiguredView: View {
  18. var body: some View {
  19. VStack(spacing: 16) {
  20. Spacer()
  21. Image(systemName: "cloud.fill")
  22. .font(.system(size: 48))
  23. .foregroundStyle(.tertiary)
  24. Text("Chad Music Not Configured")
  25. .font(.title3)
  26. .foregroundStyle(.secondary)
  27. Text("Set your server URL and API key in Settings → Chad Music.")
  28. .font(.callout)
  29. .foregroundStyle(.tertiary)
  30. .multilineTextAlignment(.center)
  31. Spacer()
  32. }
  33. .frame(maxWidth: .infinity, maxHeight: .infinity)
  34. }
  35. }
  36. // MARK: - Category List
  37. private struct CategoryListView: View {
  38. let apiClient: ChadMusicAPIClient
  39. /// Show albums and artists by default — the most useful categories.
  40. private let defaultCategories: [ChadCategoryType] = [.album, .artist, .genre, .year]
  41. var body: some View {
  42. VStack(alignment: .leading, spacing: 0) {
  43. // Header with stats
  44. CloudHeaderView(apiClient: apiClient)
  45. List {
  46. Section("Browse") {
  47. ForEach(defaultCategories) { category in
  48. NavigationLink(value: category) {
  49. Label(category.displayName, systemImage: category.icon)
  50. }
  51. }
  52. }
  53. Section("More") {
  54. ForEach(ChadCategoryType.allCases.filter { !defaultCategories.contains($0) }) { category in
  55. NavigationLink(value: category) {
  56. Label(category.displayName, systemImage: category.icon)
  57. }
  58. }
  59. }
  60. }
  61. .listStyle(.sidebar)
  62. .navigationDestination(for: ChadCategoryType.self) { category in
  63. CategoryDetailView(apiClient: apiClient, category: category)
  64. }
  65. .navigationDestination(for: ChadAlbum.self) { album in
  66. AlbumDetailView(apiClient: apiClient, album: album)
  67. }
  68. .navigationDestination(for: CategoryFilter.self) { filter in
  69. FilteredAlbumsView(apiClient: apiClient, filter: filter)
  70. }
  71. }
  72. }
  73. }
  74. // MARK: - Cloud Header (stats bar)
  75. private struct CloudHeaderView: View {
  76. let apiClient: ChadMusicAPIClient
  77. @State private var stats: ChadStats?
  78. @State private var statsError = false
  79. var body: some View {
  80. HStack(spacing: 8) {
  81. Image(systemName: "cloud.fill")
  82. .foregroundStyle(.secondary)
  83. if statsError {
  84. Text("Could not load stats")
  85. .font(.caption)
  86. .foregroundStyle(.tertiary)
  87. } else if let stats {
  88. let parts = [
  89. stats.tracks.map { "\($0) tracks" },
  90. stats.albums.map { "\($0) albums" },
  91. stats.artists.map { "\($0) artists" },
  92. ].compactMap { $0 }
  93. Text(parts.joined(separator: " · "))
  94. .font(.caption)
  95. .foregroundStyle(.secondary)
  96. } else {
  97. Text("Loading...")
  98. .font(.caption)
  99. .foregroundStyle(.tertiary)
  100. }
  101. Spacer()
  102. }
  103. .padding(.horizontal, 16)
  104. .padding(.vertical, 8)
  105. .background(.bar)
  106. .task {
  107. do {
  108. stats = try await apiClient.fetchStats()
  109. } catch {
  110. statsError = true
  111. }
  112. }
  113. }
  114. }
  115. // MARK: - Filtered Albums View (artist/genre/year → albums)
  116. private struct FilteredAlbumsView: View {
  117. let apiClient: ChadMusicAPIClient
  118. let filter: CategoryFilter
  119. @State private var albums: [ChadAlbum] = []
  120. @State private var isLoading = true
  121. @State private var error: String?
  122. @Environment(\.modelContext) private var modelContext
  123. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  124. var body: some View {
  125. Group {
  126. if isLoading {
  127. ProgressView("Loading albums...")
  128. .frame(maxWidth: .infinity, maxHeight: .infinity)
  129. } else if let error {
  130. VStack(spacing: 8) {
  131. Image(systemName: "exclamationmark.triangle")
  132. .font(.title)
  133. .foregroundStyle(.secondary)
  134. Text(error)
  135. .foregroundStyle(.secondary)
  136. Button("Retry") { loadAlbums() }
  137. }
  138. .frame(maxWidth: .infinity, maxHeight: .infinity)
  139. } else if albums.isEmpty {
  140. Text("No albums found")
  141. .foregroundStyle(.secondary)
  142. .frame(maxWidth: .infinity, maxHeight: .infinity)
  143. } else {
  144. List {
  145. // Header — draggable to add all albums by this artist/genre/etc.
  146. HStack {
  147. VStack(alignment: .leading, spacing: 2) {
  148. Text(filter.value)
  149. .font(.title2.bold())
  150. Text("\(albums.count) albums")
  151. .font(.caption)
  152. .foregroundStyle(.tertiary)
  153. }
  154. Spacer()
  155. Menu {
  156. ForEach(allPlaylists) { playlist in
  157. Button(playlist.name) {
  158. addAllAlbumsToPlaylist(playlist: playlist)
  159. }
  160. }
  161. } label: {
  162. Label("Add All", systemImage: "plus.circle")
  163. .font(.caption)
  164. }
  165. .menuStyle(.borderlessButton)
  166. .fixedSize()
  167. }
  168. .listRowSeparator(.hidden)
  169. .padding(.vertical, 4)
  170. .contextMenu {
  171. Menu("Add All to Playlist") {
  172. ForEach(allPlaylists) { playlist in
  173. Button(playlist.name) {
  174. addAllAlbumsToPlaylist(playlist: playlist)
  175. }
  176. }
  177. }
  178. }
  179. // Album rows
  180. ForEach(albums) { album in
  181. NavigationLink(value: album) {
  182. HStack {
  183. VStack(alignment: .leading, spacing: 2) {
  184. Text(album.title)
  185. .lineLimit(1)
  186. if let artist = album.artist {
  187. Text(artist)
  188. .font(.caption)
  189. .foregroundStyle(.secondary)
  190. .lineLimit(1)
  191. }
  192. }
  193. Spacer()
  194. if let count = album.trackCount {
  195. Text("\(count)")
  196. .font(.caption)
  197. .foregroundStyle(.secondary)
  198. }
  199. }
  200. }
  201. .contextMenu {
  202. Menu("Add Album to Playlist") {
  203. ForEach(allPlaylists) { playlist in
  204. Button(playlist.name) {
  205. addAlbumToPlaylist(album, playlist: playlist)
  206. }
  207. }
  208. }
  209. }
  210. .draggable(album)
  211. }
  212. }
  213. .listStyle(.inset)
  214. }
  215. }
  216. .navigationTitle(filter.value)
  217. .task { loadAlbums() }
  218. }
  219. private func loadAlbums() {
  220. isLoading = true
  221. error = nil
  222. Task {
  223. do {
  224. albums = try await apiClient.fetchAlbums(filteredBy: filter.category.rawValue, value: filter.value)
  225. } catch {
  226. self.error = error.localizedDescription
  227. }
  228. isLoading = false
  229. }
  230. }
  231. private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
  232. Task.detached {
  233. guard let tracks = try? await apiClient.fetchAlbumTracks(albumId: album.id) else { return }
  234. await MainActor.run {
  235. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.isCloud == true })
  236. let existing = (try? modelContext.fetch(descriptor)) ?? []
  237. let existingById = Dictionary(uniqueKeysWithValues: existing.compactMap { t in
  238. t.cloudTrackId.map { ($0, t) }
  239. })
  240. for chadTrack in tracks {
  241. let track = existingById[chadTrack.id] ?? {
  242. let t = Track.fromCloud(chadTrack)
  243. modelContext.insert(t)
  244. return t
  245. }()
  246. playlist.addTrack(track)
  247. }
  248. }
  249. }
  250. }
  251. private func addAllAlbumsToPlaylist(playlist: Playlist) {
  252. for album in albums {
  253. addAlbumToPlaylist(album, playlist: playlist)
  254. }
  255. }
  256. }
  257. // MARK: - Category Detail (list of albums/artists/etc.)
  258. private struct CategoryDetailView: View {
  259. let apiClient: ChadMusicAPIClient
  260. let category: ChadCategoryType
  261. @State private var items: [ChadCategory] = []
  262. @State private var albums: [ChadAlbum] = []
  263. @State private var isLoading = true
  264. @State private var error: String?
  265. @State private var bulkAddingAlbum: String? // album ID being bulk-added
  266. @Environment(\.modelContext) private var modelContext
  267. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  268. /// Album category returns [ChadAlbum], all others return [ChadCategory].
  269. private var isAlbumCategory: Bool { category == .album }
  270. var body: some View {
  271. Group {
  272. if isLoading {
  273. ProgressView("Loading \(category.displayName)...")
  274. .frame(maxWidth: .infinity, maxHeight: .infinity)
  275. } else if let error {
  276. VStack(spacing: 8) {
  277. Image(systemName: "exclamationmark.triangle")
  278. .font(.title)
  279. .foregroundStyle(.secondary)
  280. Text(error)
  281. .foregroundStyle(.secondary)
  282. Button("Retry") { loadItems() }
  283. }
  284. .frame(maxWidth: .infinity, maxHeight: .infinity)
  285. } else if isAlbumCategory {
  286. List(albums) { album in
  287. NavigationLink(value: album) {
  288. HStack {
  289. VStack(alignment: .leading, spacing: 2) {
  290. Text(album.title)
  291. .lineLimit(1)
  292. if let artist = album.artist {
  293. Text(artist)
  294. .font(.caption)
  295. .foregroundStyle(.secondary)
  296. .lineLimit(1)
  297. }
  298. }
  299. Spacer()
  300. if bulkAddingAlbum == album.id {
  301. ProgressView()
  302. .controlSize(.small)
  303. } else if let count = album.trackCount {
  304. Text("\(count)")
  305. .font(.caption)
  306. .foregroundStyle(.secondary)
  307. }
  308. }
  309. }
  310. .contextMenu {
  311. Menu("Add Album to Playlist") {
  312. ForEach(allPlaylists) { playlist in
  313. Button(playlist.name) {
  314. addAlbumToPlaylist(album, playlist: playlist)
  315. }
  316. }
  317. }
  318. }
  319. .draggable(album)
  320. }
  321. .listStyle(.inset)
  322. } else {
  323. List(items) { item in
  324. NavigationLink(value: CategoryFilter(category: category, value: item.name)) {
  325. categoryRow(item)
  326. }
  327. }
  328. .listStyle(.inset)
  329. }
  330. }
  331. .navigationTitle(category.displayName)
  332. .task { loadItems() }
  333. }
  334. private func categoryRow(_ item: ChadCategory) -> some View {
  335. HStack {
  336. Text(item.name)
  337. Spacer()
  338. if let count = item.count {
  339. Text("\(count)")
  340. .font(.caption)
  341. .foregroundStyle(.secondary)
  342. }
  343. }
  344. }
  345. private func loadItems() {
  346. isLoading = true
  347. error = nil
  348. Task {
  349. do {
  350. if isAlbumCategory {
  351. albums = try await apiClient.fetchAlbums()
  352. } else {
  353. items = try await apiClient.fetchCategory(category)
  354. }
  355. } catch {
  356. self.error = error.localizedDescription
  357. }
  358. isLoading = false
  359. }
  360. }
  361. private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
  362. bulkAddingAlbum = album.id
  363. Task.detached {
  364. let chadTracks: [ChadTrack]
  365. do {
  366. chadTracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
  367. } catch {
  368. print("CloudBrowser: Failed to fetch album tracks: \(error)")
  369. await MainActor.run { bulkAddingAlbum = nil }
  370. return
  371. }
  372. await MainActor.run {
  373. bulkInsertCloudTracks(chadTracks, into: playlist)
  374. bulkAddingAlbum = nil
  375. }
  376. }
  377. }
  378. private func bulkInsertCloudTracks(_ chadTracks: [ChadTrack], into playlist: Playlist) {
  379. // Batch dedup: fetch all existing cloud tracks in one query
  380. let ids = chadTracks.map(\.id)
  381. let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { track in
  382. track.isCloud == true
  383. })
  384. let existingTracks = (try? modelContext.fetch(descriptor)) ?? []
  385. let existingById = Dictionary(uniqueKeysWithValues: existingTracks.compactMap { t in
  386. t.cloudTrackId.map { ($0, t) }
  387. })
  388. for chadTrack in chadTracks {
  389. let track = existingById[chadTrack.id] ?? {
  390. let newTrack = Track.fromCloud(chadTrack)
  391. modelContext.insert(newTrack)
  392. return newTrack
  393. }()
  394. playlist.addTrack(track)
  395. }
  396. }
  397. }
  398. // MARK: - Album Detail (track list with play buttons)
  399. private struct AlbumDetailView: View {
  400. let apiClient: ChadMusicAPIClient
  401. let album: ChadAlbum
  402. @Environment(PlayerViewModel.self) private var playerVM
  403. @Environment(\.modelContext) private var modelContext
  404. @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
  405. @State private var tracks: [ChadTrack] = []
  406. @State private var isLoading = true
  407. @State private var error: String?
  408. var body: some View {
  409. Group {
  410. if isLoading {
  411. ProgressView("Loading tracks...")
  412. .frame(maxWidth: .infinity, maxHeight: .infinity)
  413. } else if let error {
  414. VStack(spacing: 8) {
  415. Image(systemName: "exclamationmark.triangle")
  416. .font(.title)
  417. .foregroundStyle(.secondary)
  418. Text(error)
  419. .foregroundStyle(.secondary)
  420. Button("Retry") { loadTracks() }
  421. }
  422. .frame(maxWidth: .infinity, maxHeight: .infinity)
  423. } else {
  424. List {
  425. // Album header — draggable to add whole album to playlist
  426. VStack(alignment: .leading, spacing: 4) {
  427. Text(album.title)
  428. .font(.title2.bold())
  429. if let artist = album.artist {
  430. Text(artist)
  431. .font(.title3)
  432. .foregroundStyle(.secondary)
  433. }
  434. HStack {
  435. Text("\(tracks.count) tracks")
  436. .font(.caption)
  437. .foregroundStyle(.tertiary)
  438. Spacer()
  439. Menu {
  440. ForEach(allPlaylists) { playlist in
  441. Button(playlist.name) {
  442. addAllToPlaylist(playlist: playlist)
  443. }
  444. }
  445. } label: {
  446. Label("Add All", systemImage: "plus.circle")
  447. .font(.caption)
  448. }
  449. .menuStyle(.borderlessButton)
  450. .fixedSize()
  451. }
  452. }
  453. .listRowSeparator(.hidden)
  454. .padding(.vertical, 8)
  455. .draggable(album)
  456. .contextMenu {
  457. Menu("Add Album to Playlist") {
  458. ForEach(allPlaylists) { playlist in
  459. Button(playlist.name) {
  460. addAllToPlaylist(playlist: playlist)
  461. }
  462. }
  463. }
  464. }
  465. // Track rows
  466. ForEach(tracks) { track in
  467. CloudTrackRow(
  468. track: track,
  469. isPlaying: playerVM.isCloudPlayback && (
  470. playerVM.currentCloudTrack?.id == track.id ||
  471. playerVM.currentTrack?.cloudTrackId == track.id
  472. )
  473. )
  474. .contentShape(Rectangle())
  475. .onTapGesture {
  476. playCloudTrack(track)
  477. }
  478. .onDrag {
  479. let data = try? JSONEncoder().encode(track)
  480. let provider = NSItemProvider()
  481. if let data {
  482. provider.registerDataRepresentation(
  483. forTypeIdentifier: "com.mixboard.chad-track",
  484. visibility: .all
  485. ) { completion in
  486. completion(data, nil)
  487. return nil
  488. }
  489. }
  490. return provider
  491. }
  492. .contextMenu {
  493. Button {
  494. playCloudTrack(track)
  495. } label: {
  496. Label("Play", systemImage: "play")
  497. }
  498. Divider()
  499. Menu("Add to Playlist") {
  500. ForEach(allPlaylists) { playlist in
  501. Button(playlist.name) {
  502. addToPlaylist(track, playlist: playlist)
  503. }
  504. }
  505. }
  506. }
  507. }
  508. }
  509. .listStyle(.inset)
  510. }
  511. }
  512. .navigationTitle(album.title)
  513. .task { loadTracks() }
  514. }
  515. private func loadTracks() {
  516. isLoading = true
  517. error = nil
  518. Task {
  519. do {
  520. tracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
  521. } catch {
  522. self.error = error.localizedDescription
  523. }
  524. isLoading = false
  525. }
  526. }
  527. private func playCloudTrack(_ track: ChadTrack) {
  528. guard let url = apiClient.streamURL(for: track.url) else {
  529. print("CloudBrowser: Failed to build stream URL for \(track.url)")
  530. return
  531. }
  532. playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: apiClient.authHeaders)
  533. }
  534. private func addToPlaylist(_ chadTrack: ChadTrack, playlist: Playlist) {
  535. let cloudId = chadTrack.id
  536. let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.cloudTrackId == cloudId })
  537. let existing = try? modelContext.fetch(descriptor).first
  538. let track = existing ?? Track.fromCloud(chadTrack)
  539. if existing == nil {
  540. modelContext.insert(track)
  541. }
  542. playlist.addTrack(track)
  543. }
  544. private func addAllToPlaylist(playlist: Playlist) {
  545. for chadTrack in tracks {
  546. addToPlaylist(chadTrack, playlist: playlist)
  547. }
  548. }
  549. }
  550. // MARK: - Cloud Track Row
  551. private struct CloudTrackRow: View {
  552. let track: ChadTrack
  553. let isPlaying: Bool
  554. var body: some View {
  555. HStack(spacing: 12) {
  556. // Track number or playing indicator
  557. Group {
  558. if isPlaying {
  559. Image(systemName: "speaker.wave.2.fill")
  560. .foregroundStyle(Color.accentColor)
  561. } else if let num = track.trackNumber {
  562. Text("\(num)")
  563. .foregroundStyle(.secondary)
  564. } else {
  565. Text("—")
  566. .foregroundStyle(.tertiary)
  567. }
  568. }
  569. .font(.system(size: 12, design: .monospaced))
  570. .frame(width: 28, alignment: .trailing)
  571. // Title + artist
  572. VStack(alignment: .leading, spacing: 1) {
  573. Text(track.title)
  574. .font(.system(size: 13))
  575. .foregroundStyle(isPlaying ? Color.accentColor : .primary)
  576. .lineLimit(1)
  577. if let artist = track.artist {
  578. Text(artist)
  579. .font(.system(size: 11))
  580. .foregroundStyle(.secondary)
  581. .lineLimit(1)
  582. }
  583. }
  584. Spacer()
  585. // Duration
  586. Text(track.formattedDuration)
  587. .font(.system(size: 12, design: .monospaced))
  588. .foregroundStyle(.secondary)
  589. .frame(width: 40, alignment: .trailing)
  590. }
  591. .padding(.vertical, 2)
  592. }
  593. }