CloudBrowserView.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. import os
  2. import SwiftData
  3. import SwiftUI
  4. private let cloudLogger = Logger(subsystem: "com.mixboard", category: "CloudAdd")
  5. /// Browse cloud music from the Chad Music server.
  6. /// Presented as a sheet — browse categories → albums → tracks, play or add to playlists.
  7. struct CloudBrowserView: View {
  8. @Environment(PlayerViewModel.self) private var playerVM
  9. @EnvironmentObject private var theme: AppTheme
  10. @Environment(\.modelContext) private var modelContext
  11. @Environment(\.dismiss) private var dismiss
  12. @State private var client = ChadMusicAPIClient.shared
  13. @State private var stats: ChadStats?
  14. @State private var isLoadingStats = false
  15. @State private var errorMessage: String?
  16. var body: some View {
  17. NavigationStack {
  18. Group {
  19. if !client.isConfigured {
  20. notConfiguredView
  21. } else {
  22. categoryListView
  23. }
  24. }
  25. .navigationTitle("Cloud Music")
  26. .navigationBarTitleDisplayMode(.inline)
  27. .toolbar {
  28. ToolbarItem(placement: .topBarTrailing) {
  29. Button("Done") { dismiss() }
  30. }
  31. }
  32. }
  33. }
  34. // MARK: - Not Configured
  35. private var notConfiguredView: some View {
  36. VStack(spacing: 20) {
  37. Spacer()
  38. Image(systemName: "cloud.slash")
  39. .font(.system(size: 50))
  40. .foregroundStyle(theme.tertiaryText)
  41. Text("Not Connected")
  42. .font(.title2)
  43. .foregroundStyle(theme.secondaryText)
  44. Text("Set up your Chad Music server in Settings to browse cloud music.")
  45. .font(.subheadline)
  46. .foregroundStyle(theme.tertiaryText)
  47. .multilineTextAlignment(.center)
  48. .padding(.horizontal, 40)
  49. Spacer()
  50. }
  51. }
  52. // MARK: - Category List
  53. private var categoryListView: some View {
  54. List {
  55. if let stats {
  56. Section {
  57. HStack(spacing: 16) {
  58. statBadge(value: stats.tracks, label: "Tracks")
  59. statBadge(value: stats.albums, label: "Albums")
  60. statBadge(value: stats.artists, label: "Artists")
  61. }
  62. .frame(maxWidth: .infinity)
  63. .listRowBackground(Color.clear)
  64. .accessibilityIdentifier("cloud.stats")
  65. }
  66. }
  67. Section("Browse") {
  68. ForEach(ChadCategoryType.allCases) { category in
  69. NavigationLink {
  70. if category == .album {
  71. AlbumListView()
  72. } else {
  73. CategoryDetailView(category: category)
  74. }
  75. } label: {
  76. Label(category.displayName, systemImage: category.icon)
  77. }
  78. .accessibilityIdentifier("cloud.browse.\(category.rawValue)")
  79. }
  80. }
  81. }
  82. .listStyle(.insetGrouped)
  83. .task {
  84. await loadStats()
  85. }
  86. .refreshable {
  87. await loadStats()
  88. }
  89. }
  90. private func statBadge(value: Int?, label: String) -> some View {
  91. VStack(spacing: 2) {
  92. Text("\(value ?? 0)")
  93. .font(.system(size: 20, weight: .bold, design: .rounded))
  94. .foregroundStyle(theme.accent)
  95. Text(label)
  96. .font(.caption2)
  97. .foregroundStyle(theme.secondaryText)
  98. }
  99. .frame(maxWidth: .infinity)
  100. }
  101. private func loadStats() async {
  102. isLoadingStats = true
  103. errorMessage = nil
  104. let result = await client.testConnection()
  105. switch result {
  106. case .success(let s):
  107. stats = s
  108. case .failure(let error):
  109. errorMessage = error.localizedDescription
  110. }
  111. isLoadingStats = false
  112. }
  113. }
  114. // MARK: - Album List View
  115. struct AlbumListView: View {
  116. @Environment(PlayerViewModel.self) private var playerVM
  117. @EnvironmentObject private var theme: AppTheme
  118. @Environment(\.modelContext) private var modelContext
  119. @State private var albums: [ChadAlbum] = []
  120. @State private var isLoading = false
  121. @State private var errorMessage: String?
  122. @State private var searchText = ""
  123. private var filteredAlbums: [ChadAlbum] {
  124. if searchText.isEmpty { return albums }
  125. let query = searchText.lowercased()
  126. return albums.filter {
  127. ($0.title.lowercased().contains(query)) ||
  128. ($0.artist?.lowercased().contains(query) ?? false)
  129. }
  130. }
  131. var body: some View {
  132. Group {
  133. if isLoading && albums.isEmpty {
  134. ProgressView("Loading albums…")
  135. .accessibilityIdentifier("cloud.albums.loading")
  136. } else if let error = errorMessage, albums.isEmpty {
  137. Text(error).foregroundStyle(.red)
  138. .accessibilityIdentifier("cloud.albums.error")
  139. } else {
  140. List(filteredAlbums) { album in
  141. NavigationLink {
  142. AlbumDetailView(album: album)
  143. } label: {
  144. AlbumRow(album: album)
  145. }
  146. .accessibilityIdentifier("cloud.album.row.\(album.id)")
  147. }
  148. .listStyle(.plain)
  149. .searchable(text: $searchText, prompt: "Search albums")
  150. .accessibilityIdentifier("cloud.albums.list")
  151. }
  152. }
  153. .navigationTitle("Albums")
  154. .navigationBarTitleDisplayMode(.inline)
  155. .task {
  156. await loadAlbums()
  157. }
  158. }
  159. private func loadAlbums() async {
  160. isLoading = true
  161. do {
  162. albums = try await ChadMusicAPIClient.shared.fetchAlbums()
  163. } catch {
  164. errorMessage = error.localizedDescription
  165. }
  166. isLoading = false
  167. }
  168. }
  169. // MARK: - Album Row
  170. struct AlbumRow: View {
  171. let album: ChadAlbum
  172. @EnvironmentObject private var theme: AppTheme
  173. var body: some View {
  174. HStack(spacing: 12) {
  175. Image(systemName: "opticaldisc")
  176. .font(.title2)
  177. .foregroundStyle(theme.accent)
  178. .frame(width: 44, height: 44)
  179. .background(theme.accent.opacity(0.1))
  180. .clipShape(RoundedRectangle(cornerRadius: 8))
  181. VStack(alignment: .leading, spacing: 2) {
  182. Text(album.title)
  183. .font(.subheadline.weight(.medium))
  184. .foregroundStyle(theme.primaryText)
  185. .lineLimit(1)
  186. HStack(spacing: 4) {
  187. if let artist = album.artist {
  188. Text(artist)
  189. .font(.caption)
  190. .foregroundStyle(theme.secondaryText)
  191. .lineLimit(1)
  192. }
  193. if let year = album.year {
  194. Text("·")
  195. .foregroundStyle(theme.tertiaryText)
  196. Text("\(year)")
  197. .font(.caption)
  198. .foregroundStyle(theme.tertiaryText)
  199. }
  200. }
  201. }
  202. Spacer()
  203. if let count = album.trackCount {
  204. Text("\(count)")
  205. .font(.caption)
  206. .foregroundStyle(theme.tertiaryText)
  207. }
  208. }
  209. }
  210. }
  211. // MARK: - Category Detail View
  212. struct CategoryDetailView: View {
  213. let category: ChadCategoryType
  214. @EnvironmentObject private var theme: AppTheme
  215. @State private var items: [ChadCategory] = []
  216. @State private var isLoading = false
  217. @State private var errorMessage: String?
  218. @State private var searchText = ""
  219. private var filteredItems: [ChadCategory] {
  220. if searchText.isEmpty { return items }
  221. let query = searchText.lowercased()
  222. return items.filter { $0.name.lowercased().contains(query) }
  223. }
  224. var body: some View {
  225. Group {
  226. if isLoading && items.isEmpty {
  227. ProgressView("Loading…")
  228. .accessibilityIdentifier("cloud.category.loading")
  229. } else if let error = errorMessage, items.isEmpty {
  230. Text(error).foregroundStyle(.red)
  231. .accessibilityIdentifier("cloud.category.error")
  232. } else {
  233. List(filteredItems) { item in
  234. NavigationLink {
  235. FilteredAlbumListView(category: category, value: item.name)
  236. } label: {
  237. HStack {
  238. Text(displayName(for: item.name))
  239. .foregroundStyle(theme.primaryText)
  240. Spacer()
  241. if let count = item.count {
  242. Text("\(count)")
  243. .font(.caption)
  244. .foregroundStyle(theme.tertiaryText)
  245. }
  246. }
  247. }
  248. }
  249. .listStyle(.plain)
  250. .searchable(text: $searchText, prompt: "Search \(category.displayName.lowercased())")
  251. .accessibilityIdentifier("cloud.category.list")
  252. }
  253. }
  254. .navigationTitle(category.displayName)
  255. .navigationBarTitleDisplayMode(.inline)
  256. .task {
  257. await load()
  258. }
  259. }
  260. private func load() async {
  261. isLoading = true
  262. do {
  263. items = try await ChadMusicAPIClient.shared.fetchCategory(category)
  264. } catch {
  265. errorMessage = error.localizedDescription
  266. }
  267. isLoading = false
  268. }
  269. /// Cleans up year display (e.g. "2.012" → "2012"), passes other categories through.
  270. private func displayName(for value: String) -> String {
  271. guard category == .year else { return value }
  272. // Handle float-format years like "2.012" → 2012
  273. let cleaned = value.replacingOccurrences(of: ".", with: "")
  274. if let intVal = Int(cleaned), intVal > 1000, intVal < 3000 {
  275. return String(intVal)
  276. }
  277. if let intVal = Int(value) {
  278. return String(intVal)
  279. }
  280. return value
  281. }
  282. }
  283. // MARK: - Filtered Album List View
  284. struct FilteredAlbumListView: View {
  285. let category: ChadCategoryType
  286. let value: String
  287. @EnvironmentObject private var theme: AppTheme
  288. @State private var albums: [ChadAlbum] = []
  289. @State private var isLoading = false
  290. @State private var errorMessage: String?
  291. private var filteredAlbums: [ChadAlbum] {
  292. albums.filter { album in
  293. switch category {
  294. case .album: return album.title == value
  295. case .artist: return album.artist == value
  296. case .genre: return album.genre == value
  297. case .year: return album.year == parseYear(value)
  298. case .publisher: return album.publisher == value
  299. case .country: return album.country == value
  300. case .type: return album.type == value
  301. case .status: return album.status == value
  302. }
  303. }
  304. }
  305. var body: some View {
  306. Group {
  307. if isLoading && albums.isEmpty {
  308. ProgressView("Loading albums…")
  309. .accessibilityIdentifier("cloud.filtered.loading")
  310. } else if let error = errorMessage, albums.isEmpty {
  311. Text(error).foregroundStyle(.red)
  312. .accessibilityIdentifier("cloud.filtered.error")
  313. } else if filteredAlbums.isEmpty && !isLoading {
  314. ContentUnavailableView("No Albums", systemImage: "opticaldisc",
  315. description: Text("No albums found for \"\(value)\"."))
  316. .accessibilityIdentifier("cloud.filtered.empty")
  317. } else {
  318. List(filteredAlbums) { album in
  319. NavigationLink {
  320. AlbumDetailView(album: album)
  321. } label: {
  322. AlbumRow(album: album)
  323. }
  324. }
  325. .listStyle(.plain)
  326. .accessibilityIdentifier("cloud.filtered.list")
  327. }
  328. }
  329. .navigationTitle(value)
  330. .navigationBarTitleDisplayMode(.inline)
  331. .task {
  332. await loadAlbums()
  333. }
  334. }
  335. private func loadAlbums() async {
  336. isLoading = true
  337. do {
  338. albums = try await ChadMusicAPIClient.shared.fetchAlbums()
  339. } catch {
  340. errorMessage = error.localizedDescription
  341. }
  342. isLoading = false
  343. }
  344. private func parseYear(_ value: String) -> Int? {
  345. let cleaned = value.replacingOccurrences(of: ".", with: "")
  346. if let intVal = Int(cleaned), intVal > 1000, intVal < 3000 {
  347. return intVal
  348. }
  349. return Int(value)
  350. }
  351. }
  352. // MARK: - Album Detail View
  353. struct AlbumDetailView: View {
  354. let album: ChadAlbum
  355. @Environment(PlayerViewModel.self) private var playerVM
  356. @EnvironmentObject private var theme: AppTheme
  357. @Environment(\.modelContext) private var modelContext
  358. @AppStorage("trackTapAction") private var trackTapAction = "playNow"
  359. @State private var tracks: [ChadTrack] = []
  360. @State private var isLoading = false
  361. @State private var errorMessage: String?
  362. @State private var showAddToPlaylist = false
  363. @State private var tracksToAdd: [ChadTrack] = []
  364. @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
  365. var body: some View {
  366. Group {
  367. if isLoading && tracks.isEmpty {
  368. ProgressView("Loading tracks…")
  369. .accessibilityIdentifier("cloud.albumDetail.loading")
  370. } else if let error = errorMessage, tracks.isEmpty {
  371. Text(error).foregroundStyle(.red)
  372. .accessibilityIdentifier("cloud.albumDetail.error")
  373. } else {
  374. List {
  375. // Album header
  376. Section {
  377. VStack(spacing: 8) {
  378. Image(systemName: "opticaldisc.fill")
  379. .font(.system(size: 50))
  380. .foregroundStyle(theme.accent)
  381. Text(album.title)
  382. .font(.title3.bold())
  383. .foregroundStyle(theme.primaryText)
  384. .multilineTextAlignment(.center)
  385. if let artist = album.artist {
  386. Text(artist)
  387. .font(.subheadline)
  388. .foregroundStyle(theme.secondaryText)
  389. }
  390. HStack(spacing: 12) {
  391. if let year = album.year {
  392. Text("\(year)")
  393. }
  394. if let genre = album.genre {
  395. Text(genre)
  396. }
  397. Text("\(tracks.count) tracks")
  398. }
  399. .font(.caption)
  400. .foregroundStyle(theme.tertiaryText)
  401. }
  402. .frame(maxWidth: .infinity)
  403. .listRowBackground(Color.clear)
  404. .accessibilityIdentifier("cloud.albumDetail.header")
  405. }
  406. Section {
  407. Button {
  408. playAll()
  409. } label: {
  410. Label("Play All", systemImage: "play.fill")
  411. }
  412. Button {
  413. tracksToAdd = tracks
  414. showAddToPlaylist = true
  415. } label: {
  416. Label("Add All to Playlist", systemImage: "plus.circle")
  417. }
  418. }
  419. // Track list
  420. Section {
  421. ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
  422. CloudTrackRow(track: track, index: index + 1) {
  423. if trackTapAction == "addToQueue" {
  424. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  425. } else {
  426. playTrack(track)
  427. }
  428. }
  429. .contextMenu {
  430. Button {
  431. playTrack(track)
  432. } label: {
  433. Label("Play Now", systemImage: "play.fill")
  434. }
  435. Button {
  436. playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
  437. } label: {
  438. Label("Play Next", systemImage: "text.insert")
  439. }
  440. Button {
  441. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  442. } label: {
  443. Label("Add to Queue", systemImage: "text.append")
  444. }
  445. Divider()
  446. Button {
  447. tracksToAdd = [track]
  448. showAddToPlaylist = true
  449. } label: {
  450. Label("Add to Playlist", systemImage: "plus.circle")
  451. }
  452. }
  453. }
  454. }
  455. }
  456. .listStyle(.insetGrouped)
  457. .accessibilityIdentifier("cloud.albumDetail.trackList")
  458. }
  459. }
  460. .navigationTitle(album.title)
  461. .navigationBarTitleDisplayMode(.inline)
  462. .task {
  463. await loadTracks()
  464. }
  465. .sheet(isPresented: $showAddToPlaylist) {
  466. CloudAddToPlaylistSheet(
  467. tracksToAdd: $tracksToAdd,
  468. playlists: playlists
  469. )
  470. }
  471. }
  472. private func loadTracks() async {
  473. isLoading = true
  474. do {
  475. tracks = try await ChadMusicAPIClient.shared.fetchAlbumTracks(albumId: album.id)
  476. } catch {
  477. errorMessage = error.localizedDescription
  478. }
  479. isLoading = false
  480. }
  481. private func playTrack(_ track: ChadTrack) {
  482. let client = ChadMusicAPIClient.shared
  483. guard let url = client.streamURL(for: track.url) else { return }
  484. playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: client.authHeaders)
  485. }
  486. private func playAll() {
  487. guard let firstTrack = tracks.first else { return }
  488. playTrack(firstTrack)
  489. }
  490. }
  491. // MARK: - Cloud Track Row
  492. struct CloudTrackRow: View {
  493. let track: ChadTrack
  494. let index: Int
  495. let onTap: () -> Void
  496. @Environment(PlayerViewModel.self) private var playerVM
  497. @EnvironmentObject private var theme: AppTheme
  498. private var isCurrentlyPlaying: Bool {
  499. playerVM.isCloudPlayback && playerVM.currentCloudTrack?.id == track.id
  500. }
  501. var body: some View {
  502. Button(action: onTap) {
  503. HStack(spacing: 12) {
  504. // Track number or playing indicator
  505. ZStack {
  506. if isCurrentlyPlaying {
  507. Image(systemName: playerVM.isPlaying ? "speaker.wave.2.fill" : "speaker.fill")
  508. .font(.caption)
  509. .foregroundStyle(theme.accent)
  510. } else {
  511. Text("\(track.trackNumber ?? index)")
  512. .font(.caption.monospacedDigit())
  513. .foregroundStyle(theme.tertiaryText)
  514. }
  515. }
  516. .frame(width: 28)
  517. // Track info
  518. VStack(alignment: .leading, spacing: 1) {
  519. Text(track.title)
  520. .font(.subheadline)
  521. .foregroundStyle(isCurrentlyPlaying ? theme.accent : theme.primaryText)
  522. .lineLimit(1)
  523. if let artist = track.artist, !artist.isEmpty {
  524. Text(artist)
  525. .font(.caption)
  526. .foregroundStyle(theme.secondaryText)
  527. .lineLimit(1)
  528. }
  529. }
  530. Spacer()
  531. // Duration
  532. Text(track.formattedDuration)
  533. .font(.caption.monospacedDigit())
  534. .foregroundStyle(theme.tertiaryText)
  535. }
  536. }
  537. .buttonStyle(.plain)
  538. }
  539. }
  540. // MARK: - Cloud Add To Playlist Sheet
  541. struct CloudAddToPlaylistSheet: View {
  542. @Binding var tracksToAdd: [ChadTrack]
  543. let playlists: [Playlist]
  544. @Environment(\.modelContext) private var modelContext
  545. @Environment(\.dismiss) private var dismiss
  546. @Environment(PlaylistViewModel.self) private var playlistVM
  547. @EnvironmentObject private var theme: AppTheme
  548. @State private var showNewPlaylistAlert = false
  549. @State private var newPlaylistName = ""
  550. var body: some View {
  551. NavigationStack {
  552. List {
  553. // New Playlist button
  554. Button {
  555. newPlaylistName = ""
  556. showNewPlaylistAlert = true
  557. } label: {
  558. HStack {
  559. Image(systemName: "plus.circle.fill")
  560. .foregroundStyle(theme.accent)
  561. Text("New Playlist")
  562. .foregroundStyle(theme.accent)
  563. }
  564. }
  565. // Existing playlists
  566. ForEach(playlists) { playlist in
  567. Button {
  568. addTracks(to: playlist)
  569. dismiss()
  570. } label: {
  571. HStack {
  572. Image(systemName: "music.note.list")
  573. .foregroundStyle(theme.accent)
  574. Text(playlist.name)
  575. .foregroundStyle(theme.primaryText)
  576. Spacer()
  577. Text("\(playlist.entries.count) tracks")
  578. .font(.caption)
  579. .foregroundStyle(theme.tertiaryText)
  580. }
  581. }
  582. }
  583. }
  584. .navigationTitle("Add to Playlist")
  585. .navigationBarTitleDisplayMode(.inline)
  586. .toolbar {
  587. ToolbarItem(placement: .topBarTrailing) {
  588. Button("Cancel") { dismiss() }
  589. }
  590. }
  591. .alert("New Playlist", isPresented: $showNewPlaylistAlert) {
  592. TextField("Playlist name", text: $newPlaylistName)
  593. Button("Create") {
  594. guard !newPlaylistName.trimmingCharacters(in: .whitespaces).isEmpty else { return }
  595. let playlist = Playlist(name: newPlaylistName.trimmingCharacters(in: .whitespaces))
  596. modelContext.insert(playlist)
  597. addTracks(to: playlist)
  598. dismiss()
  599. }
  600. Button("Cancel", role: .cancel) {}
  601. } message: {
  602. Text("Enter a name for the new playlist.")
  603. }
  604. }
  605. }
  606. private func addTracks(to playlist: Playlist) {
  607. cloudLogger.notice("START — adding \(self.tracksToAdd.count) tracks to '\(playlist.name)' (current entries: \(playlist.entries.count))")
  608. var newTracks: [Track] = []
  609. for chadTrack in tracksToAdd {
  610. let track = Track.fromCloud(chadTrack)
  611. modelContext.insert(track)
  612. newTracks.append(track)
  613. cloudLogger.notice("inserted track '\(track.title)' isCloud=\(track.isCloud) cloudId=\(track.cloudTrackId ?? "nil")")
  614. }
  615. do {
  616. try modelContext.save()
  617. cloudLogger.notice("saved \(newTracks.count) tracks OK")
  618. } catch {
  619. cloudLogger.error("SAVE TRACKS FAILED: \(error)")
  620. }
  621. for track in newTracks {
  622. playlist.addTrack(track)
  623. cloudLogger.notice("added '\(track.title)' to playlist, entries now: \(playlist.entries.count)")
  624. }
  625. do {
  626. try modelContext.save()
  627. cloudLogger.notice("final save OK — playlist '\(playlist.name)' entries: \(playlist.entries.count)")
  628. } catch {
  629. cloudLogger.error("FINAL SAVE FAILED: \(error)")
  630. }
  631. for entry in playlist.entries {
  632. cloudLogger.notice("VERIFY entry pos=\(entry.position) track=\(entry.track?.title ?? "NIL")")
  633. }
  634. }
  635. }