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) } } Section("Browse") { ForEach(ChadCategoryType.allCases) { category in NavigationLink { if category == .album { AlbumListView() } else { CategoryDetailView(category: category) } } label: { Label(category.displayName, systemImage: category.icon) } } } } .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…") } else if let error = errorMessage, albums.isEmpty { Text(error).foregroundStyle(.red) } else { List(filteredAlbums) { album in NavigationLink { AlbumDetailView(album: album) } label: { AlbumRow(album: album) } } .listStyle(.plain) .searchable(text: $searchText, prompt: "Search albums") } } .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…") } else if let error = errorMessage, items.isEmpty { Text(error).foregroundStyle(.red) } else { List(filteredItems) { item in HStack { Text(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())") } } .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 } } // 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…") } else if let error = errorMessage, tracks.isEmpty { Text(error).foregroundStyle(.red) } 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) } // Play all / Add all 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) } } .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")") } } }