| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702 |
- import os
- import SwiftData
- import SwiftUI
- private let cloudLogger = Logger(subsystem: "com.mixboard", category: "CloudAdd")
- /// Browse cloud music from the Chad Music server.
- /// Presented as a sheet — browse categories → albums → tracks, play or add to playlists.
- struct CloudBrowserView: View {
- @Environment(PlayerViewModel.self) private var playerVM
- @EnvironmentObject private var theme: AppTheme
- @Environment(\.modelContext) private var modelContext
- @Environment(\.dismiss) private var dismiss
- @State private var client = ChadMusicAPIClient.shared
- @State private var stats: ChadStats?
- @State private var isLoadingStats = false
- @State private var errorMessage: String?
- var body: some View {
- NavigationStack {
- Group {
- if !client.isConfigured {
- notConfiguredView
- } else {
- categoryListView
- }
- }
- .navigationTitle("Cloud Music")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Done") { dismiss() }
- }
- }
- }
- }
- // MARK: - Not Configured
- private var notConfiguredView: some View {
- VStack(spacing: 20) {
- Spacer()
- Image(systemName: "cloud.slash")
- .font(.system(size: 50))
- .foregroundStyle(theme.tertiaryText)
- Text("Not Connected")
- .font(.title2)
- .foregroundStyle(theme.secondaryText)
- Text("Set up your Chad Music server in Settings to browse cloud music.")
- .font(.subheadline)
- .foregroundStyle(theme.tertiaryText)
- .multilineTextAlignment(.center)
- .padding(.horizontal, 40)
- Spacer()
- }
- }
- // MARK: - Category List
- private var categoryListView: some View {
- List {
- if let stats {
- Section {
- HStack(spacing: 16) {
- statBadge(value: stats.tracks, label: "Tracks")
- statBadge(value: stats.albums, label: "Albums")
- statBadge(value: stats.artists, label: "Artists")
- }
- .frame(maxWidth: .infinity)
- .listRowBackground(Color.clear)
- .accessibilityIdentifier("cloud.stats")
- }
- }
- Section("Browse") {
- ForEach(ChadCategoryType.allCases) { category in
- NavigationLink {
- if category == .album {
- AlbumListView()
- } else {
- CategoryDetailView(category: category)
- }
- } label: {
- Label(category.displayName, systemImage: category.icon)
- }
- .accessibilityIdentifier("cloud.browse.\(category.rawValue)")
- }
- }
- }
- .listStyle(.insetGrouped)
- .task {
- await loadStats()
- }
- .refreshable {
- await loadStats()
- }
- }
- private func statBadge(value: Int?, label: String) -> some View {
- VStack(spacing: 2) {
- Text("\(value ?? 0)")
- .font(.system(size: 20, weight: .bold, design: .rounded))
- .foregroundStyle(theme.accent)
- Text(label)
- .font(.caption2)
- .foregroundStyle(theme.secondaryText)
- }
- .frame(maxWidth: .infinity)
- }
- private func loadStats() async {
- isLoadingStats = true
- errorMessage = nil
- let result = await client.testConnection()
- switch result {
- case .success(let s):
- stats = s
- case .failure(let error):
- errorMessage = error.localizedDescription
- }
- isLoadingStats = false
- }
- }
- // MARK: - Album List View
- struct AlbumListView: View {
- @Environment(PlayerViewModel.self) private var playerVM
- @EnvironmentObject private var theme: AppTheme
- @Environment(\.modelContext) private var modelContext
- @State private var albums: [ChadAlbum] = []
- @State private var isLoading = false
- @State private var errorMessage: String?
- @State private var searchText = ""
- private var filteredAlbums: [ChadAlbum] {
- if searchText.isEmpty { return albums }
- let query = searchText.lowercased()
- return albums.filter {
- ($0.title.lowercased().contains(query)) ||
- ($0.artist?.lowercased().contains(query) ?? false)
- }
- }
- var body: some View {
- Group {
- if isLoading && albums.isEmpty {
- ProgressView("Loading albums…")
- .accessibilityIdentifier("cloud.albums.loading")
- } else if let error = errorMessage, albums.isEmpty {
- Text(error).foregroundStyle(.red)
- .accessibilityIdentifier("cloud.albums.error")
- } else {
- List(filteredAlbums) { album in
- NavigationLink {
- AlbumDetailView(album: album)
- } label: {
- AlbumRow(album: album)
- }
- .accessibilityIdentifier("cloud.album.row.\(album.id)")
- }
- .listStyle(.plain)
- .searchable(text: $searchText, prompt: "Search albums")
- .accessibilityIdentifier("cloud.albums.list")
- }
- }
- .navigationTitle("Albums")
- .navigationBarTitleDisplayMode(.inline)
- .task {
- await loadAlbums()
- }
- }
- private func loadAlbums() async {
- isLoading = true
- do {
- albums = try await ChadMusicAPIClient.shared.fetchAlbums()
- } catch {
- errorMessage = error.localizedDescription
- }
- isLoading = false
- }
- }
- // MARK: - Album Row
- struct AlbumRow: View {
- let album: ChadAlbum
- @EnvironmentObject private var theme: AppTheme
- var body: some View {
- HStack(spacing: 12) {
- Image(systemName: "opticaldisc")
- .font(.title2)
- .foregroundStyle(theme.accent)
- .frame(width: 44, height: 44)
- .background(theme.accent.opacity(0.1))
- .clipShape(RoundedRectangle(cornerRadius: 8))
- VStack(alignment: .leading, spacing: 2) {
- Text(album.title)
- .font(.subheadline.weight(.medium))
- .foregroundStyle(theme.primaryText)
- .lineLimit(1)
- HStack(spacing: 4) {
- if let artist = album.artist {
- Text(artist)
- .font(.caption)
- .foregroundStyle(theme.secondaryText)
- .lineLimit(1)
- }
- if let year = album.year {
- Text("·")
- .foregroundStyle(theme.tertiaryText)
- Text("\(year)")
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- }
- }
- Spacer()
- if let count = album.trackCount {
- Text("\(count)")
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- }
- }
- }
- // MARK: - Category Detail View
- struct CategoryDetailView: View {
- let category: ChadCategoryType
- @EnvironmentObject private var theme: AppTheme
- @State private var items: [ChadCategory] = []
- @State private var isLoading = false
- @State private var errorMessage: String?
- @State private var searchText = ""
- private var filteredItems: [ChadCategory] {
- if searchText.isEmpty { return items }
- let query = searchText.lowercased()
- return items.filter { $0.name.lowercased().contains(query) }
- }
- var body: some View {
- Group {
- if isLoading && items.isEmpty {
- ProgressView("Loading…")
- .accessibilityIdentifier("cloud.category.loading")
- } else if let error = errorMessage, items.isEmpty {
- Text(error).foregroundStyle(.red)
- .accessibilityIdentifier("cloud.category.error")
- } else {
- List(filteredItems) { item in
- NavigationLink {
- FilteredAlbumListView(category: category, value: item.name)
- } label: {
- HStack {
- Text(displayName(for: item.name))
- .foregroundStyle(theme.primaryText)
- Spacer()
- if let count = item.count {
- Text("\(count)")
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- }
- }
- }
- .listStyle(.plain)
- .searchable(text: $searchText, prompt: "Search \(category.displayName.lowercased())")
- .accessibilityIdentifier("cloud.category.list")
- }
- }
- .navigationTitle(category.displayName)
- .navigationBarTitleDisplayMode(.inline)
- .task {
- await load()
- }
- }
- private func load() async {
- isLoading = true
- do {
- items = try await ChadMusicAPIClient.shared.fetchCategory(category)
- } catch {
- errorMessage = error.localizedDescription
- }
- isLoading = false
- }
- /// Cleans up year display (e.g. "2.012" → "2012"), passes other categories through.
- private func displayName(for value: String) -> String {
- guard category == .year else { return value }
- // Handle float-format years like "2.012" → 2012
- let cleaned = value.replacingOccurrences(of: ".", with: "")
- if let intVal = Int(cleaned), intVal > 1000, intVal < 3000 {
- return String(intVal)
- }
- if let intVal = Int(value) {
- return String(intVal)
- }
- return value
- }
- }
- // MARK: - Filtered Album List View
- struct FilteredAlbumListView: View {
- let category: ChadCategoryType
- let value: String
- @EnvironmentObject private var theme: AppTheme
- @State private var albums: [ChadAlbum] = []
- @State private var isLoading = false
- @State private var errorMessage: String?
- private var filteredAlbums: [ChadAlbum] {
- albums.filter { album in
- switch category {
- case .album: return album.title == value
- case .artist: return album.artist == value
- case .genre: return album.genre == value
- case .year: return album.year == parseYear(value)
- case .publisher: return album.publisher == value
- case .country: return album.country == value
- case .type: return album.type == value
- case .status: return album.status == value
- }
- }
- }
- var body: some View {
- Group {
- if isLoading && albums.isEmpty {
- ProgressView("Loading albums…")
- .accessibilityIdentifier("cloud.filtered.loading")
- } else if let error = errorMessage, albums.isEmpty {
- Text(error).foregroundStyle(.red)
- .accessibilityIdentifier("cloud.filtered.error")
- } else if filteredAlbums.isEmpty && !isLoading {
- ContentUnavailableView("No Albums", systemImage: "opticaldisc",
- description: Text("No albums found for \"\(value)\"."))
- .accessibilityIdentifier("cloud.filtered.empty")
- } else {
- List(filteredAlbums) { album in
- NavigationLink {
- AlbumDetailView(album: album)
- } label: {
- AlbumRow(album: album)
- }
- }
- .listStyle(.plain)
- .accessibilityIdentifier("cloud.filtered.list")
- }
- }
- .navigationTitle(value)
- .navigationBarTitleDisplayMode(.inline)
- .task {
- await loadAlbums()
- }
- }
- private func loadAlbums() async {
- isLoading = true
- do {
- albums = try await ChadMusicAPIClient.shared.fetchAlbums()
- } catch {
- errorMessage = error.localizedDescription
- }
- isLoading = false
- }
- private func parseYear(_ value: String) -> Int? {
- let cleaned = value.replacingOccurrences(of: ".", with: "")
- if let intVal = Int(cleaned), intVal > 1000, intVal < 3000 {
- return intVal
- }
- return Int(value)
- }
- }
- // MARK: - Album Detail View
- struct AlbumDetailView: View {
- let album: ChadAlbum
- @Environment(PlayerViewModel.self) private var playerVM
- @EnvironmentObject private var theme: AppTheme
- @Environment(\.modelContext) private var modelContext
- @AppStorage("trackTapAction") private var trackTapAction = "playNow"
- @State private var tracks: [ChadTrack] = []
- @State private var isLoading = false
- @State private var errorMessage: String?
- @State private var showAddToPlaylist = false
- @State private var tracksToAdd: [ChadTrack] = []
- @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
- var body: some View {
- Group {
- if isLoading && tracks.isEmpty {
- ProgressView("Loading tracks…")
- .accessibilityIdentifier("cloud.albumDetail.loading")
- } else if let error = errorMessage, tracks.isEmpty {
- Text(error).foregroundStyle(.red)
- .accessibilityIdentifier("cloud.albumDetail.error")
- } else {
- List {
- // Album header
- Section {
- VStack(spacing: 8) {
- Image(systemName: "opticaldisc.fill")
- .font(.system(size: 50))
- .foregroundStyle(theme.accent)
- Text(album.title)
- .font(.title3.bold())
- .foregroundStyle(theme.primaryText)
- .multilineTextAlignment(.center)
- if let artist = album.artist {
- Text(artist)
- .font(.subheadline)
- .foregroundStyle(theme.secondaryText)
- }
- HStack(spacing: 12) {
- if let year = album.year {
- Text("\(year)")
- }
- if let genre = album.genre {
- Text(genre)
- }
- Text("\(tracks.count) tracks")
- }
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- .frame(maxWidth: .infinity)
- .listRowBackground(Color.clear)
- .accessibilityIdentifier("cloud.albumDetail.header")
- }
- Section {
- Button {
- playAll()
- } label: {
- Label("Play All", systemImage: "play.fill")
- }
- Button {
- tracksToAdd = tracks
- showAddToPlaylist = true
- } label: {
- Label("Add All to Playlist", systemImage: "plus.circle")
- }
- }
- // Track list
- Section {
- ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
- CloudTrackRow(track: track, index: index + 1) {
- if trackTapAction == "addToQueue" {
- playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
- } else {
- playTrack(track)
- }
- }
- .contextMenu {
- Button {
- playTrack(track)
- } label: {
- Label("Play Now", systemImage: "play.fill")
- }
- Button {
- playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
- } label: {
- Label("Play Next", systemImage: "text.insert")
- }
- Button {
- playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
- } label: {
- Label("Add to Queue", systemImage: "text.append")
- }
- Divider()
- Button {
- tracksToAdd = [track]
- showAddToPlaylist = true
- } label: {
- Label("Add to Playlist", systemImage: "plus.circle")
- }
- }
- }
- }
- }
- .listStyle(.insetGrouped)
- .accessibilityIdentifier("cloud.albumDetail.trackList")
- }
- }
- .navigationTitle(album.title)
- .navigationBarTitleDisplayMode(.inline)
- .task {
- await loadTracks()
- }
- .sheet(isPresented: $showAddToPlaylist) {
- CloudAddToPlaylistSheet(
- tracksToAdd: $tracksToAdd,
- playlists: playlists
- )
- }
- }
- private func loadTracks() async {
- isLoading = true
- do {
- tracks = try await ChadMusicAPIClient.shared.fetchAlbumTracks(albumId: album.id)
- } catch {
- errorMessage = error.localizedDescription
- }
- isLoading = false
- }
- private func playTrack(_ track: ChadTrack) {
- let client = ChadMusicAPIClient.shared
- guard let url = client.streamURL(for: track.url) else { return }
- playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: client.authHeaders)
- }
- private func playAll() {
- guard let firstTrack = tracks.first else { return }
- playTrack(firstTrack)
- }
- }
- // MARK: - Cloud Track Row
- struct CloudTrackRow: View {
- let track: ChadTrack
- let index: Int
- let onTap: () -> Void
- @Environment(PlayerViewModel.self) private var playerVM
- @EnvironmentObject private var theme: AppTheme
- private var isCurrentlyPlaying: Bool {
- playerVM.isCloudPlayback && playerVM.currentCloudTrack?.id == track.id
- }
- var body: some View {
- Button(action: onTap) {
- HStack(spacing: 12) {
- // Track number or playing indicator
- ZStack {
- if isCurrentlyPlaying {
- Image(systemName: playerVM.isPlaying ? "speaker.wave.2.fill" : "speaker.fill")
- .font(.caption)
- .foregroundStyle(theme.accent)
- } else {
- Text("\(track.trackNumber ?? index)")
- .font(.caption.monospacedDigit())
- .foregroundStyle(theme.tertiaryText)
- }
- }
- .frame(width: 28)
- // Track info
- VStack(alignment: .leading, spacing: 1) {
- Text(track.title)
- .font(.subheadline)
- .foregroundStyle(isCurrentlyPlaying ? theme.accent : theme.primaryText)
- .lineLimit(1)
- if let artist = track.artist, !artist.isEmpty {
- Text(artist)
- .font(.caption)
- .foregroundStyle(theme.secondaryText)
- .lineLimit(1)
- }
- }
- Spacer()
- // Duration
- Text(track.formattedDuration)
- .font(.caption.monospacedDigit())
- .foregroundStyle(theme.tertiaryText)
- }
- }
- .buttonStyle(.plain)
- }
- }
- // MARK: - Cloud Add To Playlist Sheet
- struct CloudAddToPlaylistSheet: View {
- @Binding var tracksToAdd: [ChadTrack]
- let playlists: [Playlist]
- @Environment(\.modelContext) private var modelContext
- @Environment(\.dismiss) private var dismiss
- @Environment(PlaylistViewModel.self) private var playlistVM
- @EnvironmentObject private var theme: AppTheme
- @State private var showNewPlaylistAlert = false
- @State private var newPlaylistName = ""
- var body: some View {
- NavigationStack {
- List {
- // New Playlist button
- Button {
- newPlaylistName = ""
- showNewPlaylistAlert = true
- } label: {
- HStack {
- Image(systemName: "plus.circle.fill")
- .foregroundStyle(theme.accent)
- Text("New Playlist")
- .foregroundStyle(theme.accent)
- }
- }
- // Existing playlists
- ForEach(playlists) { playlist in
- Button {
- addTracks(to: playlist)
- dismiss()
- } label: {
- HStack {
- Image(systemName: "music.note.list")
- .foregroundStyle(theme.accent)
- Text(playlist.name)
- .foregroundStyle(theme.primaryText)
- Spacer()
- Text("\(playlist.entries.count) tracks")
- .font(.caption)
- .foregroundStyle(theme.tertiaryText)
- }
- }
- }
- }
- .navigationTitle("Add to Playlist")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Cancel") { dismiss() }
- }
- }
- .alert("New Playlist", isPresented: $showNewPlaylistAlert) {
- TextField("Playlist name", text: $newPlaylistName)
- Button("Create") {
- guard !newPlaylistName.trimmingCharacters(in: .whitespaces).isEmpty else { return }
- let playlist = Playlist(name: newPlaylistName.trimmingCharacters(in: .whitespaces))
- modelContext.insert(playlist)
- addTracks(to: playlist)
- dismiss()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Enter a name for the new playlist.")
- }
- }
- }
- private func addTracks(to playlist: Playlist) {
- cloudLogger.notice("START — adding \(self.tracksToAdd.count) tracks to '\(playlist.name)' (current entries: \(playlist.entries.count))")
- var newTracks: [Track] = []
- for chadTrack in tracksToAdd {
- let track = Track.fromCloud(chadTrack)
- modelContext.insert(track)
- newTracks.append(track)
- cloudLogger.notice("inserted track '\(track.title)' isCloud=\(track.isCloud) cloudId=\(track.cloudTrackId ?? "nil")")
- }
- do {
- try modelContext.save()
- cloudLogger.notice("saved \(newTracks.count) tracks OK")
- } catch {
- cloudLogger.error("SAVE TRACKS FAILED: \(error)")
- }
- for track in newTracks {
- playlist.addTrack(track)
- cloudLogger.notice("added '\(track.title)' to playlist, entries now: \(playlist.entries.count)")
- }
- do {
- try modelContext.save()
- cloudLogger.notice("final save OK — playlist '\(playlist.name)' entries: \(playlist.entries.count)")
- } catch {
- cloudLogger.error("FINAL SAVE FAILED: \(error)")
- }
- for entry in playlist.entries {
- cloudLogger.notice("VERIFY entry pos=\(entry.position) track=\(entry.track?.title ?? "NIL")")
- }
- }
- }
|