| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 |
- import SwiftData
- import SwiftUI
- import UniformTypeIdentifiers
- /// Sidebar — library sections, queue, playlist folders and playlists with drag & drop.
- struct SidebarView: View {
- @Binding var selection: SidebarSection?
- @Binding var showNewPlaylistSheet: 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 = ""
- @AppStorage("playbackMode") private var playbackMode: String = "queue"
- /// Playlists not in any folder.
- private var unfolderedPlaylists: [Playlist] {
- allPlaylists.filter { $0.folder == nil }
- }
- var body: some View {
- List(selection: $selection) {
- // ── Library ──────────────────────────────
- Section("Library") {
- ForEach(LibraryDestination.allCases, id: \.self) { dest in
- Label(dest.displayName, systemImage: dest.icon)
- .tag(SidebarSection.library(dest))
- }
- if playbackMode == "queue" {
- Label("Queue", systemImage: "list.bullet")
- .tag(SidebarSection.queue)
- .accessibilityIdentifier("queueButton")
- }
- if SlskdAPIClient.shared.isConfigured {
- Label("Downloads", systemImage: "arrow.down.circle")
- .tag(SidebarSection.downloads)
- }
- }
- // ── Playlists ────────────────────────────
- Section("Playlists") {
- // Folders
- ForEach(folders) { folder in
- FolderRowView(
- folder: folder,
- selection: $selection,
- 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")
- .accessibilityIdentifier("newPlaylistButton")
- 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")
- .accessibilityIdentifier("sidebar")
- .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(SidebarSection.playlist(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<Track>(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 selection: SidebarSection?
- 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(SidebarSection.playlist(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<Playlist>(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)
- }
- }
|