From 2fdeef9030b8f494946d44e55ae1868f2ccb49d1 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 6 Nov 2026 09:58:26 +0000 Subject: [PATCH] removed unwanted bar now with waveforms --- KJamsClone/DJNativeDragDrop.swift | 8 +++--- KJamsClone/DJWorkstation.swift | 38 ++++++++++++++++++++------- KJamsCloneTests/KJamsCloneTests.swift | 22 ++++++++++++++++ docs/DJ_WORKSTATION.md | 2 ++ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/KJamsClone/DJNativeDragDrop.swift b/KJamsClone/DJNativeDragDrop.swift index f89a1e6..c0c219e 100644 --- a/KJamsClone/DJNativeDragDrop.swift +++ b/KJamsClone/DJNativeDragDrop.swift @@ -18,10 +18,11 @@ struct DJLibraryDragTitle: NSViewRepresentable { let reference: SongDragReference let fontSize: Double let select: () -> Void + var doubleClick: (() -> 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.toolTip = "Drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true + view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick + 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) } } @@ -31,6 +32,7 @@ final class DJLibraryDragView: NSView { var reference: SongDragReference? var fontSize: Double = 11 var select: (() -> Void)? + var doubleClick: (() -> Void)? private var armed = false private var dragging = false override init(frame: NSRect) { super.init(frame:frame);setAccessibilityElement(true);setAccessibilityRole(.staticText) } @@ -58,7 +60,7 @@ final class DJLibraryDragView: NSView { armed = false;dragging = false;NSCursor.arrow.set() for target in DJNativeDropView.targets.allObjects { target.layer?.borderWidth = 0 } } - guard dragging,let reference,let window else { select?();return } + guard dragging,let reference,let window else { select?();if armed,event.clickCount == 2 { doubleClick?() };return } for target in DJNativeDropView.targets.allObjects where target.window === window && !target.isHiddenOrHasHiddenAncestor { if target.bounds.contains(target.convert(event.locationInWindow,from:nil)),target.validSong?(reference) == true { let receive = target.acceptSong diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index ae5b81f..c2efc2e 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -291,6 +291,15 @@ struct DJAudioDevice: Identifiable, Equatable { func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 } func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b } + private(set) var nextLibraryDeck = "A" + func loadFromLibraryDoubleClick(_ song: Song) { + guard pendingReplacement == nil else { return } + let target = nextLibraryDeck + nextLibraryDeck = target == "A" ? "B" : "A" + if channel(target).player.playing { + pendingReplacement = .init(deck:target,song:song,urls:[]) + } else { load(song,into:target) } + } func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) { perform { let deck = channel(letter) @@ -818,9 +827,6 @@ struct DJConsole: View { header DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) browser.frame(maxWidth:.infinity,maxHeight:.infinity) - if let progress = system.waveformJob { - WaveformProgressCard(progress:progress,running:system.generatingWaveforms,studio:"DJ",cancel:{system.cancelWaveforms()},details:{waveformDetails = true},dismiss:{system.dismissWaveforms()}) - } if let progress = system.importProgress { HStack { Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() @@ -844,12 +850,24 @@ struct DJConsole: View { } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } .sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) } - .sheet(isPresented:$waveformDetails) { - VStack(alignment:.leading,spacing:16) { - Text("DJ Waveform Details").font(.title2.bold()) - ScrollView { Text(system.waveformJob?.messages.joined(separator:"\n\n") ?? "").textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) } - HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) } - }.padding(24).frame(width:620,height:400) + .sheet(isPresented:Binding(get:{system.waveformJob != nil},set:{if !$0 { system.dismissWaveforms() }})) { + if let progress = system.waveformJob { + VStack(alignment:.leading,spacing:20) { + Label("DJ Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(DJStyle.a) + Text(system.generatingWaveforms ? "Preparing saved waveforms in the background." : "Completed waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary) + WaveformProgressCard(progress:progress,running:system.generatingWaveforms,studio:"DJ",cancel:{system.cancelWaveforms()},details:{waveformDetails.toggle()},dismiss:{system.dismissWaveforms()}) + if waveformDetails || !progress.messages.isEmpty { + Text("Analysis Log").font(.headline) + ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180) + } + HStack { + Text("Cancelling keeps completed waveforms.").font(.caption).foregroundStyle(.secondary) + Spacer() + if !system.generatingWaveforms { Button("Done") { system.dismissWaveforms() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) } + } + }.padding(26).frame(width:660).background(DJStyle.background).preferredColorScheme(.dark) + .interactiveDismissDisabled(system.generatingWaveforms) + } } .sheet(isPresented:$queueVisible) { VStack(spacing:16) { @@ -903,7 +921,7 @@ struct DJConsole: View { 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}).frame(maxWidth:.infinity,alignment:.leading) + 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) 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") diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 8465610..c72b2e2 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2700,4 +2700,26 @@ extension KJamsCloneTests { XCTAssertEqual(system.waveformJob?.cached,1);XCTAssertEqual(system.waveformJob?.generated,0) } + @MainActor func testDJLibraryDoubleClickAlternatesDecksAndAlwaysProtectsPlayingTrack() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let system = DJSystem(storageURL:root) + defer { system.a.unload();system.b.unload() } + let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0) + system.loadFromLibraryDoubleClick(song) + XCTAssertEqual(system.a.song?.id,1);XCTAssertNil(system.b.song) + system.loadFromLibraryDoubleClick(song) + XCTAssertEqual(system.b.song?.id,1);XCTAssertEqual(system.nextLibraryDeck,"A") + system.settings.protectPlayingDeck = false + system.a.player.toggle() + system.loadFromLibraryDoubleClick(song) + XCTAssertEqual(system.pendingReplacement?.deck,"A") + XCTAssertTrue(system.a.player.playing) + system.cancelReplacement() + system.loadFromLibraryDoubleClick(song) + XCTAssertNil(system.pendingReplacement);XCTAssertEqual(system.nextLibraryDeck,"A") + XCTAssertTrue(system.a.player.playing) + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 4b382f8..7cff3be 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -89,3 +89,5 @@ DJ library rows include the same gold favourite star as Karaoke Studio. Favourit Dragging a DJ waveform previews short audio grains through that deck’s EQ, level and output routing while paused; releasing stops the preview and retains the selected position. Scrubbing a playing deck continues playback. This is audio preview seeking, rather than reverse vinyl scratching. 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.