import SwiftData import SwiftUI import UniformTypeIdentifiers /// Sidebar — playlist folders and playlists with drag & drop. struct SidebarView: View { @Binding var selectedPlaylist: Playlist? @Binding var showNewPlaylistSheet: Bool @Binding var showCloudBrowser: Bool @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @EnvironmentObject private var theme: AppTheme @Query(sort: \Track.title) private var allTracks: [Track] @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist] @Query(sort: \PlaylistFolder.dateCreated) private var folders: [PlaylistFolder] @State private var showNewFolderAlert = false @State private var newFolderName = "" /// Playlists not in any folder. private var unfolderedPlaylists: [Playlist] { allPlaylists.filter { $0.folder == nil } } var body: some View { List(selection: $selectedPlaylist) { // Cloud Library Section("Cloud") { Button { showCloudBrowser = true selectedPlaylist = nil } label: { Label("Chad Music", systemImage: "cloud.fill") .foregroundStyle(showCloudBrowser ? Color.accentColor : .primary) } .buttonStyle(.plain) } Section("Playlists") { // Folders ForEach(folders) { folder in FolderRowView( folder: folder, selectedPlaylist: $selectedPlaylist, onDrop: { providers, playlist in handleDrop(providers: providers, playlist: playlist) } ) } // Playlists not in a folder ForEach(unfolderedPlaylists) { playlist in playlistRow(playlist) } // Actions HStack(spacing: 16) { Button { showNewPlaylistSheet = true } label: { Image(systemName: "plus.circle.fill") .font(.system(size: 18)) } .buttonStyle(.plain) .help("New Playlist") Button { newFolderName = "" showNewFolderAlert = true } label: { Image(systemName: "folder.badge.plus") .font(.system(size: 18)) } .buttonStyle(.plain) .help("New Folder") } .foregroundStyle(theme.secondaryText) } } .listStyle(.sidebar) .navigationTitle("MixBoard") .alert("New Folder", isPresented: $showNewFolderAlert) { TextField("Folder name", text: $newFolderName) Button("Cancel", role: .cancel) {} Button("Create") { guard !newFolderName.isEmpty else { return } let folder = PlaylistFolder(name: newFolderName) modelContext.insert(folder) try? modelContext.save() } } } // MARK: - Playlist Row private func playlistRow(_ playlist: Playlist) -> some View { PlaylistRow(playlist: playlist) .tag(playlist) .draggable(playlist.id.uuidString) .onDrop(of: [.chadTrack, .chadAlbum, .utf8PlainText], isTargeted: nil) { providers in handleDrop(providers: providers, playlist: playlist) return true } .contextMenu { playlistContextMenu(playlist) } } private func handleDrop(providers: [NSItemProvider], playlist: Playlist) { print("SidebarView: handleDrop called with \(providers.count) providers for playlist '\(playlist.name)'") for provider in providers { print(" Provider types: \(provider.registeredTypeIdentifiers)") // Cloud track if provider.hasItemConformingToTypeIdentifier("com.mixboard.chad-track") { print(" → Matched chad-track") provider.loadDataRepresentation(forTypeIdentifier: "com.mixboard.chad-track") { data, error in if let error { print(" → Load error: \(error)"); return } guard let data, let track = try? JSONDecoder().decode(ChadTrack.self, from: data) else { print(" → Decode failed") return } print(" → Decoded track: \(track.title)") Task { @MainActor in self.addCloudTracksToPlaylist([track], playlist: playlist) } } } // Cloud album else if provider.hasItemConformingToTypeIdentifier("com.mixboard.chad-album") { provider.loadDataRepresentation(forTypeIdentifier: "com.mixboard.chad-album") { data, _ in guard let data, let album = try? JSONDecoder().decode(ChadAlbum.self, from: data) else { return } Task { @MainActor in addCloudAlbumsToPlaylist([album], playlist: playlist) } } } // Local track ID (string) else if provider.hasItemConformingToTypeIdentifier("public.utf8-plain-text") { provider.loadItem(forTypeIdentifier: "public.utf8-plain-text") { item, _ in guard let data = item as? Data, let idString = String(data: data, encoding: .utf8) else { return } Task { @MainActor in addTracksToPlaylist(trackIDs: [idString], playlist: playlist) } } } } } @ViewBuilder private func playlistContextMenu(_ playlist: Playlist) -> some View { // Set as mix target let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3] Menu("Set as Mix Target") { ForEach(0..<3, id: \.self) { slot in let isCurrent = playlistVM.mixTargets[slot]?.id == playlist.id let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString Button(isCurrent ? "✓ Mix \(slot + 1)" : "Mix \(slot + 1) (\(hint))") { playlistVM.setMixTarget(slot, playlist: playlist) playlistVM.showStatus("\"\(playlist.name)\" → Mix \(slot + 1)") } } } Divider() // Move to folder if !folders.isEmpty { Menu("Move to Folder") { ForEach(folders) { folder in Button(folder.name) { playlist.folder = folder try? modelContext.save() } } Divider() Button("No Folder") { playlist.folder = nil try? modelContext.save() } } } Divider() Button("Delete Playlist", role: .destructive) { playlistVM.deletePlaylist(playlist, context: modelContext) } } private func addTracksToPlaylist(trackIDs: [String], playlist: Playlist) { for idString in trackIDs { guard let uuid = UUID(uuidString: idString), let track = allTracks.first(where: { $0.id == uuid }) else { continue } playlistVM.addTrack(track, to: playlist, context: modelContext) } } private func addCloudTracksToPlaylist(_ chadTracks: [ChadTrack], playlist: Playlist) { for chadTrack in chadTracks { 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 addCloudAlbumsToPlaylist(_ albums: [ChadAlbum], playlist: Playlist) { Task { let client = ChadMusicAPIClient.shared for album in albums { guard let tracks = try? await client.fetchAlbumTracks(albumId: album.id) else { continue } addCloudTracksToPlaylist(tracks, playlist: playlist) } } } } // MARK: - Folder Row private struct FolderRowView: View { let folder: PlaylistFolder @Binding var selectedPlaylist: Playlist? let onDrop: ([NSItemProvider], Playlist) -> Void @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @EnvironmentObject private var theme: AppTheme @Query(sort: \PlaylistFolder.dateCreated) private var allFolders: [PlaylistFolder] @State private var isExpanded: Bool = true @State private var showRenameAlert = false @State private var renameName = "" var body: some View { DisclosureGroup(isExpanded: $isExpanded) { ForEach(folder.sortedPlaylists) { playlist in PlaylistRow(playlist: playlist) .tag(playlist) .draggable(playlist.id.uuidString) .onDrop(of: [.chadTrack, .chadAlbum, .utf8PlainText], isTargeted: nil) { providers in onDrop(providers, playlist) return true } .contextMenu { // Set as mix target let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3] Menu("Set as Mix Target") { ForEach(0..<3, id: \.self) { slot in let isCurrent = playlistVM.mixTargets[slot]?.id == playlist.id let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString Button(isCurrent ? "✓ Mix \(slot + 1)" : "Mix \(slot + 1) (\(hint))") { playlistVM.setMixTarget(slot, playlist: playlist) playlistVM.showStatus("\"\(playlist.name)\" → Mix \(slot + 1)") } } } Divider() Button("Remove from Folder") { playlist.folder = nil try? modelContext.save() } // Move to different folder let otherFolders = allFolders.filter { $0.id != folder.id } if !otherFolders.isEmpty { Menu("Move to Folder") { ForEach(otherFolders) { f in Button(f.name) { playlist.folder = f try? modelContext.save() } } } } Divider() Button("Delete Playlist", role: .destructive) { modelContext.delete(playlist) try? modelContext.save() } } } } label: { HStack(spacing: 6) { Image(systemName: "folder.fill") .foregroundStyle(theme.accent) .font(.system(size: 14)) Text(folder.name) .foregroundStyle(theme.primaryText) Text("(\(folder.playlists.count))") .font(.caption2) .foregroundStyle(theme.tertiaryText) } .contentShape(Rectangle()) .contextMenu { Button("Rename Folder...") { renameName = folder.name showRenameAlert = true } Divider() Button("Delete Folder", role: .destructive) { for pl in folder.playlists { pl.folder = nil } modelContext.delete(folder) try? modelContext.save() } } } .dropDestination(for: String.self) { items, _ in handlePlaylistDrop(items) return true } .alert("Rename Folder", isPresented: $showRenameAlert) { TextField("Folder name", text: $renameName) Button("Cancel", role: .cancel) {} Button("Rename") { folder.name = renameName try? modelContext.save() } } .onAppear { isExpanded = folder.isExpanded } .onChange(of: isExpanded) { _, newValue in folder.isExpanded = newValue try? modelContext.save() } } private func handlePlaylistDrop(_ items: [String]) { // Items are playlist UUID strings — move them into this folder for idString in items { guard let uuid = UUID(uuidString: idString) else { continue } let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == uuid }) if let playlist = try? modelContext.fetch(descriptor).first { playlist.folder = folder } } try? modelContext.save() } } // MARK: - Playlist Row private struct PlaylistRow: View { let playlist: Playlist @EnvironmentObject private var theme: AppTheme @Environment(PlaylistViewModel.self) private var playlistVM /// Which mix slot(s) this playlist is assigned to (0, 1, 2), if any. private var assignedSlots: [Int] { (0..<3).filter { playlistVM.mixTargets[$0]?.id == playlist.id } } var body: some View { HStack { Image(systemName: assignedSlots.isEmpty ? "music.note.list" : "target") .foregroundStyle(assignedSlots.isEmpty ? (Color(hex: playlist.color) ?? theme.accent) : theme.accent) VStack(alignment: .leading, spacing: 2) { Text(playlist.name) .foregroundStyle(theme.primaryText) .lineLimit(1) Text("\(playlist.trackCount) tracks · \(playlist.formattedTotalDuration)") .font(.caption2) .foregroundStyle(theme.secondaryText) } Spacer(minLength: 4) // Show mix slot badges ForEach(assignedSlots, id: \.self) { slot in Text("\(slot + 1)") .font(.system(size: 9, weight: .bold, design: .rounded)) .frame(width: 16, height: 16) .foregroundStyle(mixTargetColors[slot]) .background(mixTargetColors[slot].opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 3)) } } } } // MARK: - Color Extension extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) let r, g, b: Double switch hex.count { case 6: r = Double((int >> 16) & 0xFF) / 255 g = Double((int >> 8) & 0xFF) / 255 b = Double(int & 0xFF) / 255 default: return nil } self.init(red: r, green: g, blue: b) } }