CloudBrowserView.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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. }
  65. }
  66. Section("Browse") {
  67. ForEach(ChadCategoryType.allCases) { category in
  68. NavigationLink {
  69. if category == .album {
  70. AlbumListView()
  71. } else {
  72. CategoryDetailView(category: category)
  73. }
  74. } label: {
  75. Label(category.displayName, systemImage: category.icon)
  76. }
  77. }
  78. }
  79. }
  80. .listStyle(.insetGrouped)
  81. .task {
  82. await loadStats()
  83. }
  84. .refreshable {
  85. await loadStats()
  86. }
  87. }
  88. private func statBadge(value: Int?, label: String) -> some View {
  89. VStack(spacing: 2) {
  90. Text("\(value ?? 0)")
  91. .font(.system(size: 20, weight: .bold, design: .rounded))
  92. .foregroundStyle(theme.accent)
  93. Text(label)
  94. .font(.caption2)
  95. .foregroundStyle(theme.secondaryText)
  96. }
  97. .frame(maxWidth: .infinity)
  98. }
  99. private func loadStats() async {
  100. isLoadingStats = true
  101. errorMessage = nil
  102. let result = await client.testConnection()
  103. switch result {
  104. case .success(let s):
  105. stats = s
  106. case .failure(let error):
  107. errorMessage = error.localizedDescription
  108. }
  109. isLoadingStats = false
  110. }
  111. }
  112. // MARK: - Album List View
  113. struct AlbumListView: View {
  114. @Environment(PlayerViewModel.self) private var playerVM
  115. @EnvironmentObject private var theme: AppTheme
  116. @Environment(\.modelContext) private var modelContext
  117. @State private var albums: [ChadAlbum] = []
  118. @State private var isLoading = false
  119. @State private var errorMessage: String?
  120. @State private var searchText = ""
  121. private var filteredAlbums: [ChadAlbum] {
  122. if searchText.isEmpty { return albums }
  123. let query = searchText.lowercased()
  124. return albums.filter {
  125. ($0.title.lowercased().contains(query)) ||
  126. ($0.artist?.lowercased().contains(query) ?? false)
  127. }
  128. }
  129. var body: some View {
  130. Group {
  131. if isLoading && albums.isEmpty {
  132. ProgressView("Loading albums…")
  133. } else if let error = errorMessage, albums.isEmpty {
  134. Text(error).foregroundStyle(.red)
  135. } else {
  136. List(filteredAlbums) { album in
  137. NavigationLink {
  138. AlbumDetailView(album: album)
  139. } label: {
  140. AlbumRow(album: album)
  141. }
  142. }
  143. .listStyle(.plain)
  144. .searchable(text: $searchText, prompt: "Search albums")
  145. }
  146. }
  147. .navigationTitle("Albums")
  148. .navigationBarTitleDisplayMode(.inline)
  149. .task {
  150. await loadAlbums()
  151. }
  152. }
  153. private func loadAlbums() async {
  154. isLoading = true
  155. do {
  156. albums = try await ChadMusicAPIClient.shared.fetchAlbums()
  157. } catch {
  158. errorMessage = error.localizedDescription
  159. }
  160. isLoading = false
  161. }
  162. }
  163. // MARK: - Album Row
  164. struct AlbumRow: View {
  165. let album: ChadAlbum
  166. @EnvironmentObject private var theme: AppTheme
  167. var body: some View {
  168. HStack(spacing: 12) {
  169. Image(systemName: "opticaldisc")
  170. .font(.title2)
  171. .foregroundStyle(theme.accent)
  172. .frame(width: 44, height: 44)
  173. .background(theme.accent.opacity(0.1))
  174. .clipShape(RoundedRectangle(cornerRadius: 8))
  175. VStack(alignment: .leading, spacing: 2) {
  176. Text(album.title)
  177. .font(.subheadline.weight(.medium))
  178. .foregroundStyle(theme.primaryText)
  179. .lineLimit(1)
  180. HStack(spacing: 4) {
  181. if let artist = album.artist {
  182. Text(artist)
  183. .font(.caption)
  184. .foregroundStyle(theme.secondaryText)
  185. .lineLimit(1)
  186. }
  187. if let year = album.year {
  188. Text("·")
  189. .foregroundStyle(theme.tertiaryText)
  190. Text("\(year)")
  191. .font(.caption)
  192. .foregroundStyle(theme.tertiaryText)
  193. }
  194. }
  195. }
  196. Spacer()
  197. if let count = album.trackCount {
  198. Text("\(count)")
  199. .font(.caption)
  200. .foregroundStyle(theme.tertiaryText)
  201. }
  202. }
  203. }
  204. }
  205. // MARK: - Category Detail View
  206. struct CategoryDetailView: View {
  207. let category: ChadCategoryType
  208. @EnvironmentObject private var theme: AppTheme
  209. @State private var items: [ChadCategory] = []
  210. @State private var isLoading = false
  211. @State private var errorMessage: String?
  212. @State private var searchText = ""
  213. private var filteredItems: [ChadCategory] {
  214. if searchText.isEmpty { return items }
  215. let query = searchText.lowercased()
  216. return items.filter { $0.name.lowercased().contains(query) }
  217. }
  218. var body: some View {
  219. Group {
  220. if isLoading && items.isEmpty {
  221. ProgressView("Loading…")
  222. } else if let error = errorMessage, items.isEmpty {
  223. Text(error).foregroundStyle(.red)
  224. } else {
  225. List(filteredItems) { item in
  226. HStack {
  227. Text(item.name)
  228. .foregroundStyle(theme.primaryText)
  229. Spacer()
  230. if let count = item.count {
  231. Text("\(count)")
  232. .font(.caption)
  233. .foregroundStyle(theme.tertiaryText)
  234. }
  235. }
  236. }
  237. .listStyle(.plain)
  238. .searchable(text: $searchText, prompt: "Search \(category.displayName.lowercased())")
  239. }
  240. }
  241. .navigationTitle(category.displayName)
  242. .navigationBarTitleDisplayMode(.inline)
  243. .task {
  244. await load()
  245. }
  246. }
  247. private func load() async {
  248. isLoading = true
  249. do {
  250. items = try await ChadMusicAPIClient.shared.fetchCategory(category)
  251. } catch {
  252. errorMessage = error.localizedDescription
  253. }
  254. isLoading = false
  255. }
  256. }
  257. // MARK: - Album Detail View
  258. struct AlbumDetailView: View {
  259. let album: ChadAlbum
  260. @Environment(PlayerViewModel.self) private var playerVM
  261. @EnvironmentObject private var theme: AppTheme
  262. @Environment(\.modelContext) private var modelContext
  263. @AppStorage("trackTapAction") private var trackTapAction = "playNow"
  264. @State private var tracks: [ChadTrack] = []
  265. @State private var isLoading = false
  266. @State private var errorMessage: String?
  267. @State private var showAddToPlaylist = false
  268. @State private var tracksToAdd: [ChadTrack] = []
  269. @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
  270. var body: some View {
  271. Group {
  272. if isLoading && tracks.isEmpty {
  273. ProgressView("Loading tracks…")
  274. } else if let error = errorMessage, tracks.isEmpty {
  275. Text(error).foregroundStyle(.red)
  276. } else {
  277. List {
  278. // Album header
  279. Section {
  280. VStack(spacing: 8) {
  281. Image(systemName: "opticaldisc.fill")
  282. .font(.system(size: 50))
  283. .foregroundStyle(theme.accent)
  284. Text(album.title)
  285. .font(.title3.bold())
  286. .foregroundStyle(theme.primaryText)
  287. .multilineTextAlignment(.center)
  288. if let artist = album.artist {
  289. Text(artist)
  290. .font(.subheadline)
  291. .foregroundStyle(theme.secondaryText)
  292. }
  293. HStack(spacing: 12) {
  294. if let year = album.year {
  295. Text("\(year)")
  296. }
  297. if let genre = album.genre {
  298. Text(genre)
  299. }
  300. Text("\(tracks.count) tracks")
  301. }
  302. .font(.caption)
  303. .foregroundStyle(theme.tertiaryText)
  304. }
  305. .frame(maxWidth: .infinity)
  306. .listRowBackground(Color.clear)
  307. }
  308. // Play all / Add all
  309. Section {
  310. Button {
  311. playAll()
  312. } label: {
  313. Label("Play All", systemImage: "play.fill")
  314. }
  315. Button {
  316. tracksToAdd = tracks
  317. showAddToPlaylist = true
  318. } label: {
  319. Label("Add All to Playlist", systemImage: "plus.circle")
  320. }
  321. }
  322. // Track list
  323. Section {
  324. ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
  325. CloudTrackRow(track: track, index: index + 1) {
  326. if trackTapAction == "addToQueue" {
  327. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  328. } else {
  329. playTrack(track)
  330. }
  331. }
  332. .contextMenu {
  333. Button {
  334. playTrack(track)
  335. } label: {
  336. Label("Play Now", systemImage: "play.fill")
  337. }
  338. Button {
  339. playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
  340. } label: {
  341. Label("Play Next", systemImage: "text.insert")
  342. }
  343. Button {
  344. playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
  345. } label: {
  346. Label("Add to Queue", systemImage: "text.append")
  347. }
  348. Divider()
  349. Button {
  350. tracksToAdd = [track]
  351. showAddToPlaylist = true
  352. } label: {
  353. Label("Add to Playlist", systemImage: "plus.circle")
  354. }
  355. }
  356. }
  357. }
  358. }
  359. .listStyle(.insetGrouped)
  360. }
  361. }
  362. .navigationTitle(album.title)
  363. .navigationBarTitleDisplayMode(.inline)
  364. .task {
  365. await loadTracks()
  366. }
  367. .sheet(isPresented: $showAddToPlaylist) {
  368. CloudAddToPlaylistSheet(
  369. tracksToAdd: $tracksToAdd,
  370. playlists: playlists
  371. )
  372. }
  373. }
  374. private func loadTracks() async {
  375. isLoading = true
  376. do {
  377. tracks = try await ChadMusicAPIClient.shared.fetchAlbumTracks(albumId: album.id)
  378. } catch {
  379. errorMessage = error.localizedDescription
  380. }
  381. isLoading = false
  382. }
  383. private func playTrack(_ track: ChadTrack) {
  384. let client = ChadMusicAPIClient.shared
  385. guard let url = client.streamURL(for: track.url) else { return }
  386. playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: client.authHeaders)
  387. }
  388. private func playAll() {
  389. guard let firstTrack = tracks.first else { return }
  390. playTrack(firstTrack)
  391. }
  392. }
  393. // MARK: - Cloud Track Row
  394. struct CloudTrackRow: View {
  395. let track: ChadTrack
  396. let index: Int
  397. let onTap: () -> Void
  398. @Environment(PlayerViewModel.self) private var playerVM
  399. @EnvironmentObject private var theme: AppTheme
  400. private var isCurrentlyPlaying: Bool {
  401. playerVM.isCloudPlayback && playerVM.currentCloudTrack?.id == track.id
  402. }
  403. var body: some View {
  404. Button(action: onTap) {
  405. HStack(spacing: 12) {
  406. // Track number or playing indicator
  407. ZStack {
  408. if isCurrentlyPlaying {
  409. Image(systemName: playerVM.isPlaying ? "speaker.wave.2.fill" : "speaker.fill")
  410. .font(.caption)
  411. .foregroundStyle(theme.accent)
  412. } else {
  413. Text("\(track.trackNumber ?? index)")
  414. .font(.caption.monospacedDigit())
  415. .foregroundStyle(theme.tertiaryText)
  416. }
  417. }
  418. .frame(width: 28)
  419. // Track info
  420. VStack(alignment: .leading, spacing: 1) {
  421. Text(track.title)
  422. .font(.subheadline)
  423. .foregroundStyle(isCurrentlyPlaying ? theme.accent : theme.primaryText)
  424. .lineLimit(1)
  425. if let artist = track.artist, !artist.isEmpty {
  426. Text(artist)
  427. .font(.caption)
  428. .foregroundStyle(theme.secondaryText)
  429. .lineLimit(1)
  430. }
  431. }
  432. Spacer()
  433. // Duration
  434. Text(track.formattedDuration)
  435. .font(.caption.monospacedDigit())
  436. .foregroundStyle(theme.tertiaryText)
  437. }
  438. }
  439. .buttonStyle(.plain)
  440. }
  441. }
  442. // MARK: - Cloud Add To Playlist Sheet
  443. struct CloudAddToPlaylistSheet: View {
  444. @Binding var tracksToAdd: [ChadTrack]
  445. let playlists: [Playlist]
  446. @Environment(\.modelContext) private var modelContext
  447. @Environment(\.dismiss) private var dismiss
  448. @Environment(PlaylistViewModel.self) private var playlistVM
  449. @EnvironmentObject private var theme: AppTheme
  450. @State private var showNewPlaylistAlert = false
  451. @State private var newPlaylistName = ""
  452. var body: some View {
  453. NavigationStack {
  454. List {
  455. // New Playlist button
  456. Button {
  457. newPlaylistName = ""
  458. showNewPlaylistAlert = true
  459. } label: {
  460. HStack {
  461. Image(systemName: "plus.circle.fill")
  462. .foregroundStyle(theme.accent)
  463. Text("New Playlist")
  464. .foregroundStyle(theme.accent)
  465. }
  466. }
  467. // Existing playlists
  468. ForEach(playlists) { playlist in
  469. Button {
  470. addTracks(to: playlist)
  471. dismiss()
  472. } label: {
  473. HStack {
  474. Image(systemName: "music.note.list")
  475. .foregroundStyle(theme.accent)
  476. Text(playlist.name)
  477. .foregroundStyle(theme.primaryText)
  478. Spacer()
  479. Text("\(playlist.entries.count) tracks")
  480. .font(.caption)
  481. .foregroundStyle(theme.tertiaryText)
  482. }
  483. }
  484. }
  485. }
  486. .navigationTitle("Add to Playlist")
  487. .navigationBarTitleDisplayMode(.inline)
  488. .toolbar {
  489. ToolbarItem(placement: .topBarTrailing) {
  490. Button("Cancel") { dismiss() }
  491. }
  492. }
  493. .alert("New Playlist", isPresented: $showNewPlaylistAlert) {
  494. TextField("Playlist name", text: $newPlaylistName)
  495. Button("Create") {
  496. guard !newPlaylistName.trimmingCharacters(in: .whitespaces).isEmpty else { return }
  497. let playlist = Playlist(name: newPlaylistName.trimmingCharacters(in: .whitespaces))
  498. modelContext.insert(playlist)
  499. addTracks(to: playlist)
  500. dismiss()
  501. }
  502. Button("Cancel", role: .cancel) {}
  503. } message: {
  504. Text("Enter a name for the new playlist.")
  505. }
  506. }
  507. }
  508. private func addTracks(to playlist: Playlist) {
  509. cloudLogger.notice("START — adding \(self.tracksToAdd.count) tracks to '\(playlist.name)' (current entries: \(playlist.entries.count))")
  510. var newTracks: [Track] = []
  511. for chadTrack in tracksToAdd {
  512. let track = Track.fromCloud(chadTrack)
  513. modelContext.insert(track)
  514. newTracks.append(track)
  515. cloudLogger.notice("inserted track '\(track.title)' isCloud=\(track.isCloud) cloudId=\(track.cloudTrackId ?? "nil")")
  516. }
  517. do {
  518. try modelContext.save()
  519. cloudLogger.notice("saved \(newTracks.count) tracks OK")
  520. } catch {
  521. cloudLogger.error("SAVE TRACKS FAILED: \(error)")
  522. }
  523. for track in newTracks {
  524. playlist.addTrack(track)
  525. cloudLogger.notice("added '\(track.title)' to playlist, entries now: \(playlist.entries.count)")
  526. }
  527. do {
  528. try modelContext.save()
  529. cloudLogger.notice("final save OK — playlist '\(playlist.name)' entries: \(playlist.entries.count)")
  530. } catch {
  531. cloudLogger.error("FINAL SAVE FAILED: \(error)")
  532. }
  533. for entry in playlist.entries {
  534. cloudLogger.notice("VERIFY entry pos=\(entry.position) track=\(entry.track?.title ?? "NIL")")
  535. }
  536. }
  537. }