import SwiftData import SwiftUI /// Navigation destination within the cloud browser — managed manually to avoid /// NavigationSplitView capturing nested NavigationLink pushes on macOS. enum CloudNavDestination: Hashable { case category(ChadCategoryType) case album(ChadAlbum) case filter(CategoryFilter) } /// Cloud library browser — navigate categories → albums → tracks from Chad Music server. struct CloudBrowserView: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme @State private var apiClient = ChadMusicAPIClient.shared @State private var uploadService = UploadService.shared @State private var navStack: [CloudNavDestination] = [] var body: some View { if !apiClient.isConfigured { CloudNotConfiguredView() } else { VStack(spacing: 0) { if let current = navStack.last { // Back button header for all detail views CloudNavHeader(navStack: $navStack, title: { switch current { case .category(let cat): cat.displayName case .album(let album): album.title case .filter(let filter): filter.value } }()) Divider() switch current { case .category(let cat): CategoryDetailView(apiClient: apiClient, category: cat, navStack: $navStack) case .album(let album): AlbumDetailView(apiClient: apiClient, album: album, navStack: $navStack) case .filter(let filter): FilteredAlbumsView(apiClient: apiClient, filter: filter, navStack: $navStack) } } else { CategoryListView(apiClient: apiClient, uploadService: uploadService, navStack: $navStack) } } } } } // MARK: - Not Configured Prompt private struct CloudNotConfiguredView: View { var body: some View { VStack(spacing: 16) { Spacer() Image(systemName: "cloud.fill") .font(.system(size: 48)) .foregroundStyle(.tertiary) Text("Chad Music Not Configured") .font(.title3) .foregroundStyle(.secondary) Text("Set your server URL and API key in Settings → Chad Music.") .font(.callout) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Navigation Header (back button + title) private struct CloudNavHeader: View { @Binding var navStack: [CloudNavDestination] let title: String @EnvironmentObject private var theme: AppTheme var body: some View { HStack(spacing: 6) { Button { navStack.removeLast() } label: { Image(systemName: "chevron.left") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(theme.primaryText) } .buttonStyle(.plain) Text(title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(theme.primaryText) .lineLimit(1) Spacer() } .padding(.horizontal, 12) .padding(.vertical, 8) } } // MARK: - Category List private struct CategoryListView: View { let apiClient: ChadMusicAPIClient let uploadService: UploadService @Binding var navStack: [CloudNavDestination] /// Show albums and artists by default — the most useful categories. private let defaultCategories: [ChadCategoryType] = [.album, .artist, .genre, .year] var body: some View { VStack(alignment: .leading, spacing: 0) { // Header with stats + upload button CloudHeaderView(apiClient: apiClient, uploadService: uploadService) List { Section("Browse") { ForEach(defaultCategories) { category in Button { navStack.append(.category(category)) } label: { Label(category.displayName, systemImage: category.icon) } .buttonStyle(.plain) } } Section("More") { ForEach(ChadCategoryType.allCases.filter { !defaultCategories.contains($0) }) { category in Button { navStack.append(.category(category)) } label: { Label(category.displayName, systemImage: category.icon) } .buttonStyle(.plain) } } } .listStyle(.sidebar) } } } // MARK: - Cloud Header (stats bar + upload) private struct CloudHeaderView: View { let apiClient: ChadMusicAPIClient let uploadService: UploadService @State private var stats: ChadStats? @State private var statsError = false @State private var showUploadError = false var body: some View { HStack(spacing: 8) { Image(systemName: "cloud.fill") .foregroundStyle(.secondary) if statsError { Text("Could not load stats") .font(.caption) .foregroundStyle(.tertiary) } else if let stats { let parts = [ stats.tracks.map { "\($0) tracks" }, stats.albums.map { "\($0) albums" }, stats.artists.map { "\($0) artists" }, ].compactMap { $0 } Text(parts.joined(separator: " · ")) .font(.caption) .foregroundStyle(.secondary) } else { Text("Loading...") .font(.caption) .foregroundStyle(.tertiary) } Spacer() uploadControl } .padding(.horizontal, 16) .padding(.vertical, 8) .background(.bar) .task { do { stats = try await apiClient.fetchStats() } catch { statsError = true } } .onChange(of: uploadService.state) { _, newState in if case .success = newState { Task { stats = try? await apiClient.fetchStats() } } } .alert("Upload Failed", isPresented: $showUploadError) { Button("OK") { uploadService.dismiss() } } message: { if case .error(let msg) = uploadService.state { Text(msg) } } } @ViewBuilder private var uploadControl: some View { switch uploadService.state { case .idle: Button { chooseFile() } label: { Label("Upload", systemImage: "arrow.up.to.cloud") .font(.caption) } .buttonStyle(.bordered) .controlSize(.small) .help("Upload to Cloud") case .uploading(let fileName): HStack(spacing: 6) { ProgressView(value: uploadService.progress) .progressViewStyle(.linear) .frame(width: 60) Button { uploadService.cancel() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 10)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Cancel upload of \(fileName)") } case .success(let added, _): HStack(spacing: 4) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) .font(.caption) Text("\(added) added") .font(.caption) .foregroundStyle(.secondary) } .onAppear { Task { try? await Task.sleep(for: .seconds(3)) uploadService.dismiss() } } case .error: Button { showUploadError = true } label: { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.red) } .buttonStyle(.plain) .help("Upload failed — click for details") .onAppear { showUploadError = true } } } private func chooseFile() { let panel = NSOpenPanel() panel.title = "Choose Audio File to Upload" panel.allowedContentTypes = UploadService.allowedTypes panel.allowsMultipleSelection = false panel.canChooseDirectories = false guard panel.runModal() == .OK, let url = panel.url else { return } uploadService.startUpload(fileURL: url, apiClient: apiClient) } } // MARK: - Filtered Albums View (artist/genre/year → albums) private struct FilteredAlbumsView: View { let apiClient: ChadMusicAPIClient let filter: CategoryFilter @Binding var navStack: [CloudNavDestination] @State private var albums: [ChadAlbum] = [] @State private var isLoading = true @State private var error: String? @Environment(\.modelContext) private var modelContext @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist] var body: some View { Group { if isLoading { ProgressView("Loading albums...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.title) .foregroundStyle(.secondary) Text(error) .foregroundStyle(.secondary) Button("Retry") { loadAlbums() } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if albums.isEmpty { Text("No albums found") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { // Header — draggable to add all albums by this artist/genre/etc. HStack { VStack(alignment: .leading, spacing: 2) { Text(filter.value) .font(.title2.bold()) Text("\(albums.count) albums") .font(.caption) .foregroundStyle(.tertiary) } Spacer() Menu { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAllAlbumsToPlaylist(playlist: playlist) } } } label: { Label("Add All", systemImage: "plus.circle") .font(.caption) } .menuStyle(.borderlessButton) .fixedSize() } .listRowSeparator(.hidden) .padding(.vertical, 4) .contextMenu { Menu("Add All to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAllAlbumsToPlaylist(playlist: playlist) } } } } // Album rows ForEach(albums) { album in Button { navStack.append(.album(album)) } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(album.title) .lineLimit(1) if let artist = album.artist { Text(artist) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() if let count = album.trackCount { Text("\(count)") .font(.caption) .foregroundStyle(.secondary) } Image(systemName: "chevron.right") .font(.caption2) .foregroundStyle(.tertiary) } } .buttonStyle(.plain) .contextMenu { Menu("Add Album to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAlbumToPlaylist(album, playlist: playlist) } } } } .draggable(album) } } .listStyle(.inset) } } .task { loadAlbums() } } private func loadAlbums() { isLoading = true error = nil Task { do { albums = try await apiClient.fetchAlbums(filteredBy: filter.category.rawValue, value: filter.value) } catch { self.error = error.localizedDescription } isLoading = false } } private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) { Task.detached { guard let tracks = try? await apiClient.fetchAlbumTracks(albumId: album.id) else { return } await MainActor.run { let descriptor = FetchDescriptor(predicate: #Predicate { $0.isCloud == true }) let existing = (try? modelContext.fetch(descriptor)) ?? [] let existingById = Dictionary(uniqueKeysWithValues: existing.compactMap { t in t.cloudTrackId.map { ($0, t) } }) for chadTrack in tracks { let track = existingById[chadTrack.id] ?? { let t = Track.fromCloud(chadTrack) modelContext.insert(t) return t }() playlist.addTrack(track) } } } } private func addAllAlbumsToPlaylist(playlist: Playlist) { for album in albums { addAlbumToPlaylist(album, playlist: playlist) } } } // MARK: - Category Detail (list of albums/artists/etc.) private struct CategoryDetailView: View { let apiClient: ChadMusicAPIClient let category: ChadCategoryType @Binding var navStack: [CloudNavDestination] @State private var items: [ChadCategory] = [] @State private var albums: [ChadAlbum] = [] @State private var isLoading = true @State private var error: String? @State private var bulkAddingAlbum: String? // album ID being bulk-added @Environment(\.modelContext) private var modelContext @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist] /// Album category returns [ChadAlbum], all others return [ChadCategory]. private var isAlbumCategory: Bool { category == .album } var body: some View { Group { if isLoading { ProgressView("Loading \(category.displayName)...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.title) .foregroundStyle(.secondary) Text(error) .foregroundStyle(.secondary) Button("Retry") { loadItems() } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if isAlbumCategory { List(albums) { album in Button { navStack.append(.album(album)) } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(album.title) .lineLimit(1) if let artist = album.artist { Text(artist) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() if bulkAddingAlbum == album.id { ProgressView() .controlSize(.small) } else if let count = album.trackCount { Text("\(count)") .font(.caption) .foregroundStyle(.secondary) } Image(systemName: "chevron.right") .font(.caption2) .foregroundStyle(.tertiary) } } .buttonStyle(.plain) .contextMenu { Menu("Add Album to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAlbumToPlaylist(album, playlist: playlist) } } } } .draggable(album) } .listStyle(.inset) } else { List(items) { item in Button { navStack.append(.filter(CategoryFilter(category: category, value: item.name))) } label: { HStack { categoryRow(item) Image(systemName: "chevron.right") .font(.caption2) .foregroundStyle(.tertiary) } } .buttonStyle(.plain) } .listStyle(.inset) } } .task { loadItems() } } private func categoryRow(_ item: ChadCategory) -> some View { HStack { Text(item.name) Spacer() if let count = item.count { Text("\(count)") .font(.caption) .foregroundStyle(.secondary) } } } private func loadItems() { isLoading = true error = nil Task { do { if isAlbumCategory { albums = try await apiClient.fetchAlbums() } else { items = try await apiClient.fetchCategory(category) } } catch { self.error = error.localizedDescription } isLoading = false } } private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) { bulkAddingAlbum = album.id Task.detached { let chadTracks: [ChadTrack] do { chadTracks = try await apiClient.fetchAlbumTracks(albumId: album.id) } catch { print("CloudBrowser: Failed to fetch album tracks: \(error)") await MainActor.run { bulkAddingAlbum = nil } return } await MainActor.run { bulkInsertCloudTracks(chadTracks, into: playlist) bulkAddingAlbum = nil } } } private func bulkInsertCloudTracks(_ chadTracks: [ChadTrack], into playlist: Playlist) { // Batch dedup: fetch all existing cloud tracks in one query let ids = chadTracks.map(\.id) let descriptor = FetchDescriptor(predicate: #Predicate { track in track.isCloud == true }) let existingTracks = (try? modelContext.fetch(descriptor)) ?? [] let existingById = Dictionary(uniqueKeysWithValues: existingTracks.compactMap { t in t.cloudTrackId.map { ($0, t) } }) for chadTrack in chadTracks { let track = existingById[chadTrack.id] ?? { let newTrack = Track.fromCloud(chadTrack) modelContext.insert(newTrack) return newTrack }() playlist.addTrack(track) } } } // MARK: - Album Detail (track list with play buttons) private struct AlbumDetailView: View { let apiClient: ChadMusicAPIClient let album: ChadAlbum @Binding var navStack: [CloudNavDestination] @Environment(PlayerViewModel.self) private var playerVM @Environment(\.modelContext) private var modelContext @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist] @State private var tracks: [ChadTrack] = [] @State private var isLoading = true @State private var error: String? @AppStorage("playbackMode") private var playbackMode: String = "queue" @State private var downloadManager = DownloadManager.shared /// Persisted Track objects for cloud tracks (used for download state tracking). @State private var persistedTracks: [String: Track] = [:] var body: some View { Group { if isLoading { ProgressView("Loading tracks...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .font(.title) .foregroundStyle(.secondary) Text(error) .foregroundStyle(.secondary) Button("Retry") { loadTracks() } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { // Album header — draggable to add whole album to playlist VStack(alignment: .leading, spacing: 4) { Text(album.title) .font(.title2.bold()) if let artist = album.artist { Text(artist) .font(.title3) .foregroundStyle(.secondary) } HStack { Text("\(tracks.count) tracks") .font(.caption) .foregroundStyle(.tertiary) Spacer() AlbumDownloadButton( tracks: Array(persistedTracks.values), apiClient: apiClient ) Menu { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAllToPlaylist(playlist: playlist) } } } label: { Label("Add All", systemImage: "plus.circle") .font(.caption) } .menuStyle(.borderlessButton) .fixedSize() } } .listRowSeparator(.hidden) .padding(.vertical, 8) .draggable(album) .contextMenu { if playbackMode == "queue" { Button { for track in tracks { playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track)) } } label: { Label("Play Album Next", systemImage: "text.line.first.and.arrowtriangle.forward") } Button { for track in tracks { playerVM.addToQueue(QueueEntry.from(cloudTrack: track)) } } label: { Label("Add Album to Queue", systemImage: "text.append") } Divider() } Menu("Add Album to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAllToPlaylist(playlist: playlist) } } } Divider() Button { let persisted = tracks.map { ensurePersistedTrack(for: $0) } downloadManager.downloadBatch(tracks: persisted, apiClient: apiClient) } label: { Label("Download All", systemImage: "arrow.down.circle") } } // Track rows ForEach(tracks) { track in CloudTrackRow( track: track, isPlaying: playerVM.isCloudPlayback && ( playerVM.currentCloudTrack?.id == track.id || playerVM.currentTrack?.cloudTrackId == track.id ), persistedTrack: persistedTracks[track.id], onDownload: { let persisted = ensurePersistedTrack(for: track) downloadManager.download(track: persisted, apiClient: apiClient) } ) .contentShape(Rectangle()) .onTapGesture { playCloudTrack(track) } .onDrag { let data = try? JSONEncoder().encode(track) let provider = NSItemProvider() if let data { provider.registerDataRepresentation( forTypeIdentifier: "com.mixboard.chad-track", visibility: .all ) { completion in completion(data, nil) return nil } } return provider } .contextMenu { Button { playCloudTrack(track) } label: { Label("Play", systemImage: "play") } Divider() if playbackMode == "queue" { Button { playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track)) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") } Button { playerVM.addToQueue(QueueEntry.from(cloudTrack: track)) } label: { Label("Add to Queue", systemImage: "text.append") } Divider() } // Download actions if let persisted = persistedTracks[track.id] { downloadContextMenuItems(for: persisted) Divider() } else { Button { let persisted = ensurePersistedTrack(for: track) downloadManager.download(track: persisted, apiClient: apiClient) } label: { Label("Download", systemImage: "arrow.down.circle") } Divider() } Menu("Add to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addToPlaylist(track, playlist: playlist) } } } } } } .listStyle(.inset) } } .task { loadTracks() loadPersistedTracks() } } private func loadTracks() { isLoading = true error = nil Task { do { tracks = try await apiClient.fetchAlbumTracks(albumId: album.id) loadPersistedTracks() } catch { self.error = error.localizedDescription } isLoading = false } } private func loadPersistedTracks() { let descriptor = FetchDescriptor(predicate: #Predicate { $0.isCloud == true }) guard let existing = try? modelContext.fetch(descriptor) else { return } var map: [String: Track] = [:] for t in existing { if let id = t.cloudTrackId { map[id] = t } } persistedTracks = map } /// Ensure a ChadTrack has a persisted SwiftData Track. Returns the persisted track. private func ensurePersistedTrack(for chadTrack: ChadTrack) -> Track { if let existing = persistedTracks[chadTrack.id] { return existing } let track = Track.fromCloud(chadTrack) modelContext.insert(track) persistedTracks[chadTrack.id] = track return track } @ViewBuilder private func downloadContextMenuItems(for track: Track) -> some View { switch track.downloadState { case .none: Button { downloadManager.download(track: track, apiClient: apiClient) } label: { Label("Download", systemImage: "arrow.down.circle") } case .downloading: Button { downloadManager.cancel(track: track) } label: { Label("Cancel Download", systemImage: "stop.circle") } case .downloaded: Button(role: .destructive) { downloadManager.removeDownload(track: track) } label: { Label("Remove Download", systemImage: "trash") } case .error: Button { downloadManager.download(track: track, apiClient: apiClient) } label: { Label("Retry Download", systemImage: "arrow.clockwise") } } } private func playCloudTrack(_ track: ChadTrack) { guard let url = apiClient.streamURL(for: track.url) else { print("CloudBrowser: Failed to build stream URL for \(track.url)") return } playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: apiClient.authHeaders) } private func addToPlaylist(_ chadTrack: ChadTrack, playlist: Playlist) { let cloudId = chadTrack.id let descriptor = FetchDescriptor(predicate: #Predicate { $0.cloudTrackId == cloudId }) let existing = try? modelContext.fetch(descriptor).first let track = existing ?? Track.fromCloud(chadTrack) if existing == nil { modelContext.insert(track) } playlist.addTrack(track) } private func addAllToPlaylist(playlist: Playlist) { for chadTrack in tracks { addToPlaylist(chadTrack, playlist: playlist) } } } // MARK: - Cloud Track Row private struct CloudTrackRow: View { let track: ChadTrack let isPlaying: Bool var persistedTrack: Track? var onDownload: (() -> Void)? = nil var body: some View { HStack(spacing: 12) { // Track number or playing indicator Group { if isPlaying { Image(systemName: "speaker.wave.2.fill") .foregroundStyle(Color.accentColor) } else if let num = track.trackNumber { Text("\(num)") .foregroundStyle(.secondary) } else { Text("—") .foregroundStyle(.tertiary) } } .font(.system(size: 12, design: .monospaced)) .frame(width: 28, alignment: .trailing) // Title + artist VStack(alignment: .leading, spacing: 1) { Text(track.title) .font(.system(size: 13)) .foregroundStyle(isPlaying ? Color.accentColor : .primary) .lineLimit(1) if let artist = track.artist { Text(artist) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() // Upload / download indicator for cloud tracks if let persistedTrack { DownloadIndicator(track: persistedTrack) } else { Button { onDownload?() } label: { Image(systemName: "arrow.down.circle") .font(.system(size: 14)) .foregroundStyle(.tertiary) .frame(width: 20, height: 20) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Download for offline playback") } // Duration Text(track.formattedDuration) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 40, alignment: .trailing) } .padding(.vertical, 2) } }