diff --git a/KJamsClone/DJNativeDragDrop.swift b/KJamsClone/DJNativeDragDrop.swift index c0c219e..a007cfe 100644 --- a/KJamsClone/DJNativeDragDrop.swift +++ b/KJamsClone/DJNativeDragDrop.swift @@ -19,9 +19,10 @@ struct DJLibraryDragTitle: NSViewRepresentable { let fontSize: Double let select: () -> Void var doubleClick: (() -> Void)? = nil + var selectAll: (() -> Void)? = nil func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() } func updateNSView(_ view: DJLibraryDragView,context: Context) { - view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick + view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick;view.selectAllTracks = selectAll view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true } func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) } @@ -33,6 +34,9 @@ final class DJLibraryDragView: NSView { var fontSize: Double = 11 var select: (() -> Void)? var doubleClick: (() -> Void)? + var selectAllTracks: (() -> Void)? + override var acceptsFirstResponder: Bool { true } + @objc override func selectAll(_ sender: Any?) { selectAllTracks?() } private var armed = false private var dragging = false override init(frame: NSRect) { super.init(frame:frame);setAccessibilityElement(true);setAccessibilityRole(.staticText) } @@ -44,7 +48,7 @@ final class DJLibraryDragView: NSView { let rect = NSRect(x:0,y:(bounds.height-ceil(font.ascender-font.descender))/2,width:bounds.width,height:ceil(font.ascender-font.descender)+2) (title as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:NSColor.white,.paragraphStyle:paragraph]) } - override func mouseDown(with event: NSEvent) { armed = true;dragging = false } + override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self);armed = true;dragging = false } override func mouseDragged(with event: NSEvent) { guard armed else { return } dragging = true diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index c2efc2e..374eee3 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -809,6 +809,7 @@ struct DJConsole: View { @State private var query = "" @State private var playlist: Int64? @State private var playlistIDs = [Int64]() + @State private var selection = Set() @State private var selected: Int64? @State private var playlistEditor = false @State private var playlistName = "" @@ -819,6 +820,19 @@ struct DJConsole: View { if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} } return all } + private var selectedTracks: [Song] { songs.filter { selection.contains($0.id) } } + private func selectTrack(_ id: Int64) { + let modifiers = NSEvent.modifierFlags + if modifiers.contains(.shift),let anchor = selected,let first = songs.firstIndex(where:{$0.id == anchor}),let last = songs.firstIndex(where:{$0.id == id}) { + let range = Set(songs[min(first,last)...max(first,last)].map(\.id)) + if modifiers.contains(.command) { selection.formUnion(range) } else { selection = range } + } else if modifiers.contains(.command) { + if !selection.insert(id).inserted { selection.remove(id) } + selected = id + } else { selection = [id];selected = id } + } + private func selectAllTracks() { selection = Set(songs.map(\.id));selected = songs.first?.id } + private func contextTracks(_ song: Song) -> [Song] { selection.contains(song.id) ? selectedTracks : [song] } private var selectedSong: Song? { system.songs.first{$0.id == selected} } private var pages: Int { max(1,(songs.count+49)/50) } private var visible: [Song] { Array(songs.dropFirst(min(page,pages-1)*50).prefix(50)) } @@ -879,7 +893,8 @@ struct DJConsole: View { .alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } .onAppear { refreshPlaylist();connectMIDI() } .onChange(of:selected) { _ in connectMIDI() } - .onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil } + .onChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id };connectMIDI() } + .onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] } .onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() } .onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() } .onChange(of:query) { _ in page = 0 } @@ -909,7 +924,8 @@ struct DJConsole: View { Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.foregroundStyle(favouritesOnly ? Color.yellow : Color.secondary).help("Show favourite tracks only") Spacer(minLength:12) TextField("Search DJ music",text:$query).textFieldStyle(.roundedBorder).controlSize(.regular).frame(maxWidth:260) - Button { system.automix.enqueue(songs) } label: { Image(systemName:"text.badge.plus") }.help("Add this library or playlist to the DJ queue").disabled(songs.isEmpty) + Button { system.automix.enqueue(selectedTracks) } label: { Image(systemName:"text.badge.plus") }.help("Add selected tracks to the DJ queue").disabled(selectedTracks.isEmpty) + Text("\(selection.count) selected").font(.caption).foregroundStyle(.secondary) Button("Import Files / Folder…") { importMedia() } Spacer(minLength:0) Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0) @@ -917,11 +933,11 @@ struct DJConsole: View { Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1) }.controlSize(.small) ScrollViewReader { reader in - List(selection:$selected) { + List(selection:$selection) { ForEach(visible) { song in HStack(spacing:8) { Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)") - DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selected = song.id},doubleClick:{system.loadFromLibraryDoubleClick(song)}).frame(maxWidth:.infinity,alignment:.leading) + DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}).frame(maxWidth:.infinity,alignment:.leading) Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading) Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35) Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue") @@ -932,9 +948,10 @@ struct DJConsole: View { } if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) } }.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6)) + .onCommand(#selector(NSResponder.selectAll(_:))) { selectAllTracks() } .background(DJLibraryPageScroll { if page < pages-1 { page += 1 } }) .onChange(of:page) { _ in - selected = visible.first?.id + selected = visible.first?.id;selection = Set(visible.first.map{[$0.id]} ?? []) if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } } } } @@ -945,12 +962,15 @@ struct DJConsole: View { Button("Load into Deck B") { system.load(song,into:"B") } Divider() Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) } - Button("Add to DJ Queue") { system.automix.enqueue([song]) } + Button("Select All Songs") { selectAllTracks() }.keyboardShortcut("a",modifiers:.command) + Button("Add \(contextTracks(song).count) Selected Tracks to DJ Queue") { system.automix.enqueue(contextTracks(song)) } Menu("Waveforms") { + Button("Generate Waveforms for \(contextTracks(song).count) Selected Tracks") { system.generateWaveforms(Set(contextTracks(song).map(\.id))) } Button("Generate Waveform for This Track") { system.generateWaveforms([song.id]) } Button("Generate Waveforms for Loaded Decks") { system.generateWaveforms(Set([system.a.song?.id,system.b.song?.id].compactMap{$0})) }.disabled(system.a.song == nil && system.b.song == nil) Button("Generate Waveforms for All DJ Library Tracks") { system.generateWaveforms() } Divider() + Button("Regenerate Selected Waveforms") { system.generateWaveforms(Set(contextTracks(song).map(\.id)),force:true) } Button("Regenerate This Waveform") { system.generateWaveforms([song.id],force:true) } Button("Regenerate All DJ Library Waveforms") { system.generateWaveforms(force:true) } }.disabled(system.generatingWaveforms || system.importing) @@ -1000,7 +1020,7 @@ struct DJConsole: View { let rows = songs;guard !rows.isEmpty else { return } let current = rows.firstIndex(where:{$0.id == selected}) ?? (value > 0 ? -1 : rows.count) let index = min(rows.count-1,max(0,current+(value >= 0 ? 1 : -1))) - selected = rows[index].id;page = index/50 + selected = rows[index].id;selection = [rows[index].id];page = index/50 } else if name == "Preview selected",let selectedSong { system.previewLibrary(selectedSong) } else { system.action(name,value:value,relative:relative,selected:selectedSong) } } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 7cff3be..25e9aa6 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -91,3 +91,5 @@ Dragging a DJ waveform previews short audio grains through that deck’s EQ, lev The DJ library advances to the next page when a user scroll reaches the bottom. Page changes return the list to the top and select the first track. Right-click a DJ track and choose Waveforms to generate its cache, prepare loaded decks or the entire DJ library, or force regeneration. Batch analysis runs on a utility worker with saved-cache reuse, progress and cancellation. DJ waveform generation opens a modal with a coloured progress bar, percentage ring, counts and Cancel. The results stay visible until Done; any analysis issues are listed in the modal. + +DJ library selection supports Command-click for separate tracks, Shift-click for ranges, and Command-A or right-click Select All Songs for all matching tracks across pages. The queue toolbar action adds the selected tracks in library order. Right-click selected tracks to generate or regenerate their saved waveforms or add the selection to the DJ queue.