import SwiftData import SwiftUI /// 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 var body: some View { if !apiClient.isConfigured { CloudNotConfiguredView() } else { CategoryListView(apiClient: apiClient) } } } // 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: - Category List private struct CategoryListView: View { let apiClient: ChadMusicAPIClient /// 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 CloudHeaderView(apiClient: apiClient) List { Section("Browse") { ForEach(defaultCategories) { category in NavigationLink(value: category) { Label(category.displayName, systemImage: category.icon) } } } Section("More") { ForEach(ChadCategoryType.allCases.filter { !defaultCategories.contains($0) }) { category in NavigationLink(value: category) { Label(category.displayName, systemImage: category.icon) } } } } .listStyle(.sidebar) .navigationDestination(for: ChadCategoryType.self) { category in CategoryDetailView(apiClient: apiClient, category: category) } .navigationDestination(for: ChadAlbum.self) { album in AlbumDetailView(apiClient: apiClient, album: album) } .navigationDestination(for: CategoryFilter.self) { filter in FilteredAlbumsView(apiClient: apiClient, filter: filter) } } } } // MARK: - Cloud Header (stats bar) private struct CloudHeaderView: View { let apiClient: ChadMusicAPIClient @State private var stats: ChadStats? @State private var statsError = 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() } .padding(.horizontal, 16) .padding(.vertical, 8) .background(.bar) .task { do { stats = try await apiClient.fetchStats() } catch { statsError = true } } } } // MARK: - Filtered Albums View (artist/genre/year → albums) private struct FilteredAlbumsView: View { let apiClient: ChadMusicAPIClient let filter: CategoryFilter @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 NavigationLink(value: album) { 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) } } } .contextMenu { Menu("Add Album to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAlbumToPlaylist(album, playlist: playlist) } } } } .draggable(album) } } .listStyle(.inset) } } .navigationTitle(filter.value) .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 @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 NavigationLink(value: album) { 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) } } } .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 NavigationLink(value: CategoryFilter(category: category, value: item.name)) { categoryRow(item) } } .listStyle(.inset) } } .navigationTitle(category.displayName) .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 @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? 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() 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 { Menu("Add Album to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addAllToPlaylist(playlist: playlist) } } } } // Track rows ForEach(tracks) { track in CloudTrackRow( track: track, isPlaying: playerVM.isCloudPlayback && ( playerVM.currentCloudTrack?.id == track.id || playerVM.currentTrack?.cloudTrackId == track.id ) ) .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() Menu("Add to Playlist") { ForEach(allPlaylists) { playlist in Button(playlist.name) { addToPlaylist(track, playlist: playlist) } } } } } } .listStyle(.inset) } } .navigationTitle(album.title) .task { loadTracks() } } private func loadTracks() { isLoading = true error = nil Task { do { tracks = try await apiClient.fetchAlbumTracks(albumId: album.id) } catch { self.error = error.localizedDescription } isLoading = false } } 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 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() // Duration Text(track.formattedDuration) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 40, alignment: .trailing) } .padding(.vertical, 2) } }