diff --git a/KJamsClone/DJAutomix.swift b/KJamsClone/DJAutomix.swift index f76b7a2..f63450a 100644 --- a/KJamsClone/DJAutomix.swift +++ b/KJamsClone/DJAutomix.swift @@ -152,22 +152,22 @@ struct DJQueuePane: View { VStack(alignment:.leading,spacing:8) { HStack { Label("DJ Queue",systemImage:"text.line.first.and.arrowtriangle.forward").font(.headline);Spacer();Text("\(automix.entries.count)").foregroundStyle(.secondary) } HStack { - Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor) - Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil) + Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor).help("Start or stop automatic transitions through the queued tracks") + Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil).help("Transition immediately to the next queued track using the selected fade duration") }.controlSize(.small) HStack { - Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1);Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34) + Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1).help("Duration in seconds for each automatic crossfade");Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34) }.font(.caption) - Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption) + Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption).help("Match confirmed track tempos before the automatic transition") if let progress = automix.progress { ProgressView(value:progress) } List { ForEach(automix.entries) { entry in HStack(spacing:5) { VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } Spacer(minLength:0) - Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier") - Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later") - Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue") + Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier").help("Move this queued track one place earlier") + Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later").help("Move this queued track one place later") + Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue").help("Remove this track from the queue without deleting it from the library") }.font(.caption).buttonStyle(.borderless) } if automix.entries.isEmpty { Text("Add songs with + or drag them here from the DJ library.").font(.caption).foregroundStyle(.secondary) } @@ -183,7 +183,7 @@ struct DJQueuePane: View { } return true } - HStack { Button("Shuffle") { automix.shuffle() };Button("Clear") { automix.clear() };Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty) + HStack { Button("Shuffle") { automix.shuffle() }.help("Randomise the order of the remaining queued tracks");Button("Clear") { automix.clear() }.help("Remove all queued entries; library tracks are kept");Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty) Text(automix.message).font(.caption2).foregroundStyle(.secondary).lineLimit(2) }.padding(12).background(Color.white.opacity(0.035),in:RoundedRectangle(cornerRadius:6)) } diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index 713a634..4543747 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -137,11 +137,11 @@ struct DJLoopEditor: View { var body: some View { VStack(alignment:.leading,spacing:18) { Text("Deck \(letter) · Loop Points").font(.title2.bold()) - HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time } } - HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time } } + HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time }.help("Use the current playback position as the loop start") } + HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time }.help("Use the current playback position as the loop end") } Text(valid ? String(format:"Loop length: %.3f seconds",end-start) : "End must be at least 0.1 seconds after start, within the track.").font(.caption).foregroundStyle(valid ? Color.secondary : Color.orange) Text("On either waveform, drag the green edges to resize or the loop region to move. Shift-drag defines a new loop. Playback continues while editing.").font(.callout).foregroundStyle(.secondary) - HStack { Button("Cancel") { dismiss() };Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction) } + HStack { Button("Cancel") { dismiss() }.help("Close without changing the loop");Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction).help("Apply these start/end points and enable looping") } }.padding(24).frame(width:540) } } diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 71a9fec..e607bec 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -981,7 +981,7 @@ private struct DJQueueButton: View { let open: () -> Void var body: some View { Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") } - .tint(automix.enabled ? .green : .accentColor) + .tint(automix.enabled ? .green : .accentColor).help("Open the DJ queue, reorder tracks and control Automix") } } @@ -1004,6 +1004,15 @@ enum DJCollection: String,CaseIterable,Identifiable { case all = "All Music",favourites = "Favourites",rated = "Top Rated",unrated = "Unrated",missingArtist = "Needs Metadata" var id: String { rawValue } var icon: String { switch self { case .all:return "music.note.list";case .favourites:return "star.fill";case .rated:return "rosette";case .unrated:return "star";case .missingArtist:return "tag" } } + func includes(_ song: Song,favourites: Set) -> Bool { + switch self { + case .all:return true + case .favourites:return favourites.contains(song.id) + case .rated:return song.rating >= 4 + case .unrated:return song.rating == 0 + case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty + } + } } enum DJLibrarySort: String,CaseIterable,Identifiable { case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order" @@ -1016,13 +1025,7 @@ enum DJLibraryBrowser { else { source = songs } let filtered = source.filter { song in guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false } - switch collection { - case .all:return true - case .favourites:return favourites.contains(song.id) - case .rated:return song.rating >= 4 - case .unrated:return song.rating == 0 - case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty - } + return collection.includes(song,favourites:favourites) } guard sort != .source else { let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered @@ -1079,7 +1082,7 @@ struct DJConsole: View { libraryRevision = UUID() filteredTracks = DJLibraryBrowser.rows(system.songs,collection:collection,favourites:system.favourites,playlist:playlist == nil ? nil : playlistIDs,query:query,sort:librarySort,ascending:ascending) collectionCounts = Dictionary(uniqueKeysWithValues:DJCollection.allCases.map { scope in - (scope,DJLibraryBrowser.rows(system.songs,collection:scope,favourites:system.favourites,sort:.source).count) + (scope,system.songs.lazy.filter{!$0.audio.hasPrefix("showscreen:") && scope.includes($0,favourites:system.favourites)}.count) }) selection.formIntersection(Set(filteredTracks.map(\.id))) page = min(page,max(0,(filteredTracks.count+49)/50-1)) @@ -1112,7 +1115,7 @@ struct DJConsole: View { .onTapGesture(count:2) { performanceHeight = 454 } .gesture(DragGesture(coordinateSpace:.global).onChanged { value in if performanceDragStart == nil { performanceDragStart = performanceSize(geometry.size.height) } - performanceHeight = min(max(418,(performanceDragStart ?? 454)+value.translation.height),max(418,geometry.size.height-220)) + performanceHeight = min(max(418,(performanceDragStart ?? 454)+value.translation.height),min(580,max(418,geometry.size.height-220))) }.onEnded { _ in performanceDragStart = nil }) .accessibilityLabel("Resize DJ performance area") DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity) @@ -1200,11 +1203,11 @@ struct DJConsole: View { ZStack { RoundedRectangle(cornerRadius:10).fill(DJStyle.a.opacity(0.14));Image(systemName:"waveform.path").font(.system(size:20,weight:.semibold)).foregroundStyle(DJStyle.a) }.frame(width:40,height:40) VStack(alignment:.leading,spacing:3) { Text("DJ STUDIO").font(.system(size:15,weight:.bold,design:.rounded)).tracking(2);Text("PERFORMANCE WORKSPACE").font(.system(size:8,weight:.medium)).tracking(1.8).foregroundStyle(.secondary) } Spacer() - HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28) } + HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28).help("Adjust the overall DJ output level for both decks") } Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)) - Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)) + Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)).help("Open the ten-band graphic equaliser and sound presets for both decks") DJQueueButton(automix:system.automix,open:{queueVisible = true}) - if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green) } + if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green).help("Stop independent headphone preview") } }.controlSize(.small).padding(.horizontal,2).padding(.bottom,2) } private var libraryTitle: String { playlist.flatMap { id in system.playlists.first{$0.id == id}?.name } ?? collection.rawValue } @@ -1234,7 +1237,7 @@ struct DJConsole: View { private func collectionRow(_ name: String,icon: String,count: Int?,active: Bool,action: @escaping () -> Void) -> some View { Button(action:action) { HStack(spacing:9) { Image(systemName:icon).frame(width:16).foregroundStyle(active ? DJStyle.a : .secondary);Text(name).lineLimit(1);Spacer(minLength:2);if let count { Text(count.formatted()).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) } }.font(.system(size:11,weight:active ? .semibold : .regular)).padding(.horizontal,12).padding(.vertical,9).background(active ? DJStyle.a.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:7)) - }.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "") + }.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "").help("Show \(name)") } private var browser: some View { GeometryReader { geometry in @@ -1254,26 +1257,26 @@ struct DJConsole: View { HStack(spacing:12) { VStack(alignment:.leading,spacing:3) { Text(libraryTitle).font(.system(size:17,weight:.semibold));Text("\(songs.count.formatted()) tracks").font(.caption).foregroundStyle(.secondary) } Spacer(minLength:8) - StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340) - Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true)) + StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340).help("Filter this collection or playlist by title, artist, album or other track metadata") + Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true)).help("Import audio files or folders, including subfolders; already imported tracks are skipped") }.padding(14) HStack(spacing:10) { - Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155) + Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155).help("Sort the visible tracks; playlist/import order preserves the original sequence") Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.buttonStyle(.plain).help("Reverse sort order") LibrarySelectionBadge(count:selection.count) Spacer(minLength:4) - Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty) + Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty).help("Add every selected track to the DJ queue") HStack(spacing:8) { - Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0) + Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0).help("Previous library page") Text("\(min(page,pages-1)+1) / \(pages)").font(.system(size:10).monospacedDigit()).fixedSize() - Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1) + Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1).help("Next library page") }.buttonStyle(.plain) }.controlSize(.small).padding(.horizontal,14).padding(.bottom,10) Divider().overlay(Color.white.opacity(0.05)) ScrollViewReader { reader in Table(visible,selection:$selection) { TableColumn("Fav") { song in - 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)") + 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)").help(system.favourites.contains(song.id) ? "Remove this track from Favourites" : "Add this track to Favourites") }.width(38) TableColumn("Title") { song in DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}) @@ -1303,9 +1306,9 @@ struct DJConsole: View { Image(systemName:"music.note").foregroundStyle(DJStyle.a) VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:11,weight:.semibold)).lineLimit(1);Text(song.artist.isEmpty ? "Artist not set" : song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() - Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)) - Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)) - Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b)) + Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).help("Edit the selected track’s title, artist, album and metadata") + Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)).help("Load the selected track into deck A; a playing deck is protected") + Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b)).help("Load the selected track into deck B; a playing deck is protected") }.padding(10).background(DJStyle.background) } }.frame(minWidth:530,maxWidth:.infinity,maxHeight:.infinity).background(DJStyle.panel) @@ -1402,7 +1405,7 @@ private struct DJDeckSurfaces { Button("SYNC") { system.sync(deck) }.foregroundStyle(system.syncActive ? color : Color.secondary).font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(system.syncActive ? color : nil,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Match the other deck to this deck’s BPM; click again to restore both original tempos").accessibilityIdentifier("dj.\(deck.letter).sync") Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).buttonStyle(.plain).foregroundStyle(.secondary).help("Reset tempo") DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:44,height:74) - HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) };Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) } }.buttonStyle(.plain).font(.system(size:15,weight:.medium)) + HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) }.help("Decrease tempo by 0.1 percentage points");Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) }.help("Increase tempo by 0.1 percentage points") }.buttonStyle(.plain).font(.system(size:15,weight:.medium)) }.frame(width:62).disabled(!player.canJog) } var tonePanel: some View { @@ -1417,7 +1420,7 @@ private struct DJDeckSurfaces { HStack(spacing:3) { VStack(spacing:5) { ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) } - }.accessibilityLabel("Deck output meter") + }.accessibilityLabel("Deck output meter").help("Live output peak level for this deck") DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:70) } Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary) @@ -1427,30 +1430,76 @@ private struct DJDeckSurfaces { } } -struct DJKnob: View { +struct DJKnob: NSViewRepresentable { let label: String @Binding var value: Double let range: ClosedRange let reset: Double let color: Color var format: (Double) -> String = {String(format:"%+.0f dB",$0)} - @State private var initial: Double? - private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } - var body: some View { - VStack(spacing:1) { - ZStack { - Circle().fill(Color.black.opacity(0.35)).overlay { Circle().stroke(.white.opacity(0.12),lineWidth:2) } - Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90)) - Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270)) - }.frame(width:29,height:29).contentShape(Circle()) - .simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil}) - Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary) - }.frame(minWidth:36).contentShape(Rectangle()) - .highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil }) - .accessibilityAction(named:Text("Reset to default")) { value = reset;initial = nil } - .help("\(label): \(format(value)). Drag vertically; double-click to reset.") - .accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value)) - .accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) } + func makeCoordinator() -> Coordinator { Coordinator(value:$value) } + func makeNSView(context: Context) -> DJRotaryControl { + let view = DJRotaryControl();view.changed = { [weak coordinator = context.coordinator] value in coordinator?.value.wrappedValue = value };return view + } + func updateNSView(_ view: DJRotaryControl,context: Context) { + context.coordinator.value = $value + view.label = label;view.value = value;view.range = range;view.defaultValue = reset;view.colour = NSColor(color);view.isEnabled = context.environment.isEnabled + view.setAccessibilityElement(true);view.setAccessibilityRole(.slider);view.setAccessibilityLabel(label);view.setAccessibilityValue(format(value)) + view.setAccessibilityCustomActions([NSAccessibilityCustomAction(name:"Reset to default",handler:{[weak view] in view?.resetToDefault();return true})]) + view.toolTip = "\(label): \(format(value)). Drag vertically; double-click to reset." + view.needsDisplay = true + } + func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJRotaryControl,context: Context) -> CGSize? { CGSize(width:36,height:40) } + final class Coordinator { + var value: Binding + init(value: Binding) { self.value = value } + } +} +/// Native click counting keeps a double-click reset independent of drag recognition. +final class DJRotaryControl: NSControl { + var label = "",value: Double = 0,defaultValue: Double = 0 + var range: ClosedRange = -12...12 + var colour = NSColor.systemTeal + var changed: ((Double) -> Void)? + private var dragStart: (point: NSPoint,value: Double)? + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + func resetToDefault() { guard isEnabled else { return };dragStart = nil;set(defaultValue) } + private func set(_ next: Double) { value = min(range.upperBound,max(range.lowerBound,next));needsDisplay = true;changed?(value) } + override func mouseDown(with event: NSEvent) { + guard isEnabled else { return } + window?.makeFirstResponder(self) + if event.clickCount >= 2 { resetToDefault();return } + dragStart = (convert(event.locationInWindow,from:nil),value) + } + override func mouseDragged(with event: NSEvent) { + guard isEnabled,let start = dragStart else { return } + let point = convert(event.locationInWindow,from:nil) + set(start.value-Double(point.y-start.point.y)*(range.upperBound-range.lowerBound)/120) + } + override func mouseUp(with event: NSEvent) { dragStart = nil } + override func keyDown(with event: NSEvent) { + if event.keyCode == 126 || event.keyCode == 124 { set(value+(range.upperBound-range.lowerBound)/100) } + else if event.keyCode == 125 || event.keyCode == 123 { set(value-(range.upperBound-range.lowerBound)/100) } + else if event.keyCode == 49 { resetToDefault() } + else { super.keyDown(with:event) } + } + override func accessibilityPerformIncrement() -> Bool { guard isEnabled else { return false };set(value+(range.upperBound-range.lowerBound)/100);return true } + override func accessibilityPerformDecrement() -> Bool { guard isEnabled else { return false };set(value-(range.upperBound-range.lowerBound)/100);return true } + override func draw(_ dirtyRect: NSRect) { + let centre = NSPoint(x:bounds.midX,y:14.5) + let circle = NSBezierPath(ovalIn:NSRect(x:centre.x-14.5,y:0,width:29,height:29)) + NSColor.black.withAlphaComponent(0.35).setFill();circle.fill() + NSColor.white.withAlphaComponent(0.12).setStroke();circle.lineWidth = 2;circle.stroke() + let arc = NSBezierPath();arc.appendArc(withCenter:centre,radius:14,startAngle:135,endAngle:405,clockwise:false) + colour.withAlphaComponent(isEnabled ? 0.4 : 0.15).setStroke();arc.lineWidth = 2;arc.stroke() + let fraction = min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) + let angle = (-135+fraction*270) * .pi/180 + let marker = NSBezierPath();marker.move(to:NSPoint(x:centre.x+sin(angle)*5,y:centre.y-cos(angle)*5));marker.line(to:NSPoint(x:centre.x+sin(angle)*11,y:centre.y-cos(angle)*11));marker.lineWidth = 2;marker.lineCapStyle = .round + colour.withAlphaComponent(isEnabled ? 1 : 0.3).setStroke();marker.stroke() + let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center + (label as NSString).draw(in:NSRect(x:0,y:30,width:bounds.width,height:10),withAttributes:[.font:NSFont.systemFont(ofSize:8,weight:.medium),.foregroundColor:NSColor.secondaryLabelColor,.paragraphStyle:paragraph]) } } private struct DJPlatter: NSViewRepresentable { @@ -1623,11 +1672,11 @@ struct DJHardwareFader: NSViewRepresentable { slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false - slider.setAccessibilityLabel(label) + slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust." slider.setContentHuggingPriority(.defaultLow,for:.horizontal) return slider } - func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label) } + func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust." } func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSSlider,context: Context) -> CGSize? { CGSize(width:proposal.width ?? (vertical ? 52 : 240),height:proposal.height ?? (vertical ? 94 : 36)) } final class Coordinator: NSObject { var value: Binding @@ -1694,9 +1743,9 @@ struct DJContinuousConsole: View { VStack(spacing:8) { HStack { Text("CHANNEL MIXER");Spacer();Text(system.routing.mode.uppercased()).foregroundStyle(.secondary) }.font(.system(size:8,weight:.bold)).tracking(1) HStack(alignment:.top,spacing:12) { - VStack(spacing:4) { Text("A").foregroundStyle(DJStyle.a);DJDeckSurfaces(deck:system.a,player:system.a.player,system:system).tonePanel } + DJMixerChannel(deck:system.a,player:system.a.player,system:system) Divider().frame(height:152) - VStack(spacing:4) { Text("B").foregroundStyle(DJStyle.b);DJDeckSurfaces(deck:system.b,player:system.b.player,system:system).tonePanel } + DJMixerChannel(deck:system.b,player:system.b.player,system:system) }.font(.system(size:9,weight:.bold)) HStack(spacing:8) { Text("A").foregroundStyle(DJStyle.a) @@ -1708,6 +1757,17 @@ struct DJContinuousConsole: View { } } +private struct DJMixerChannel: View { + @ObservedObject var deck: DJChannel + @ObservedObject var player: Player + let system: DJSystem + var body: some View { + VStack(spacing:4) { + Text(deck.letter).foregroundStyle(DJStyle.color(deck.letter)) + DJDeckSurfaces(deck:deck,player:player,system:system).tonePanel + } + } +} private struct DJTrackHeader: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @@ -1723,16 +1783,16 @@ private struct DJTrackHeader: View { Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1) HStack(spacing:6) { Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1) - TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM") + TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM").help("Set the track’s original BPM to enable beat sync and beat loops") Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary) - Button("Tap") { deck.tap() }.controlSize(.mini) + Button("Tap") { deck.tap() }.controlSize(.mini).help("Tap along with the beat to set this track’s BPM") }.font(.system(size:10)).disabled(!player.canJog) } Spacer(minLength:4) - Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()) - Button("Load",action:load).controlSize(.mini) + Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()).help("Track time remaining") + Button("Load",action:load).controlSize(.mini).help("Load the selected DJ library track into deck \(deck.letter)") } - DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22) + DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22).help("Track overview: click or drag to seek to a position in the song") } if deck.letter == "B" { artwork } }.padding(10).frame(maxWidth:.infinity) @@ -1772,37 +1832,74 @@ private struct DJConsoleTransport: View { @ObservedObject var player: Player @ObservedObject var system: DJSystem private var playButton: some View { - Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) }.foregroundStyle(player.playing ? Color.green : Color.white).accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play") + Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) } + .foregroundStyle(player.playing ? Color.green : Color.white).help("Play or pause deck \(deck.letter)").accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play") + } + private var cueButton: some View { + Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22) + .help("Pause and return to the main cue at \(DJStyle.stamp(player.cuePosition)). Right-click to set or clear it.") + .contextMenu { Button("Set Cue Here") { player.setCue() };Button("Clear Main Cue") { player.clearCue() } } } - private var cueButton: some View { Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22).contextMenu { Button("Set cue here") { player.setCue() } } } private var loopButtons: some View { HStack(spacing:0) { - Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) } - Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle beat loop; requires confirmed BPM") - Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) } + Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }.help("Halve the loop length; updates an active loop at its next boundary") + Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle a \(deck.loopBeats)-beat loop; requires confirmed BPM") + Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }.help("Double the loop length; updates an active loop at its next boundary") } } - private var headphones: some View { Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) } + private var headphones: some View { + Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) + .help("Toggle deck \(deck.letter) headphone preview using the configured cue output") + } + private var clearMarkers: some View { + Menu { + Menu("Clear Individual Hot Cue") { + ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) } + } + Button("Clear All Hot Cues") { player.clearHotCues() }.disabled(player.hotCues.allSatisfy{$0 == nil}) + Button("Clear Main Cue") { player.clearCue() }.disabled(player.cuePosition == 0) + Button("Clear Loop Markers") { player.clearLoop() }.disabled(player.loopIn == nil && player.loopOut == nil) + Divider() + Button("Clear All Cue & Loop Markers") { player.clearDeckMarkers() } + } label: { Image(systemName:"eraser") } + .help("Clear individual hot cues, all hot cues, the main cue, loop markers or all markers without unloading the track") + .accessibilityLabel("Deck \(deck.letter) clear markers") + } var body: some View { VStack(spacing:6) { - HStack(spacing:8) { - playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) - - }.controlSize(.regular) + HStack(spacing:8) { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }.controlSize(.regular) HStack(spacing:4) { - ForEach(0..<4) { index in Button("\(index+1)") { system.hotCue(index,deck:deck) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)).contextMenu { Button("Clear hot cue") { player.hotCue(index,clear:true) } } } + ForEach(0..<4) { index in + Button("\(index+1)") { + if NSEvent.modifierFlags.contains(.option) { player.hotCue(index,clear:true) } + else { system.hotCue(index,deck:deck) } + }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)) + .help(player.hotCues[index].map{"Hot cue \(index+1) at \(DJStyle.stamp($0)). Click to jump; Option-click or right-click to clear."} ?? "Click to set hot cue \(index+1) at the current position. Option-click or right-click clears it.") + .contextMenu { Button("Clear Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) } + } Menu { Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) } Button("Set Cue Here") { player.setCue() } + Button("Clear Main Cue") { player.clearCue() } Button("Edit Loop…") { system.loopEditor = deck.letter } - } label: { Image(systemName:"flag") } - Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() } - Menu { Button("Stop") { player.stopDeck() };Button("Exit loop") { player.clearLoop() };Button("Nudge back") { player.jog(-0.05) };Button("Nudge forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") } + } label: { Image(systemName:"flag") }.help("Set, edit or clear the main cue; open the loop start/end editor") + Button("IN") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop start at the current position. Right-click to clear the loop markers.") + .contextMenu { Button("Clear Loop Markers") { player.clearLoop() } } + Button("OUT") { player.markLoopOut() }.foregroundStyle(player.loopOut == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop end and enable the loop. Set IN first; right-click to clear both markers.") + .contextMenu { Button("Clear Loop Markers") { player.clearLoop() } } + clearMarkers + Menu { + Button("Stop Deck") { player.stopDeck() } + Button("Exit Loop / Clear Loop Markers") { player.clearLoop() } + Button("Nudge Back") { player.jog(-0.05) } + Button("Nudge Forward") { player.jog(0.05) } + } label: { Image(systemName:"ellipsis") }.help("Stop the deck, exit its loop or nudge playback by 0.05 seconds") Spacer(minLength:0) }.controlSize(.mini) }.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog) } } + private struct DJCentreWaveform: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @@ -2100,9 +2197,9 @@ private struct DJCueEditor: View { Text(deck.song?.title ?? "No track").foregroundStyle(.secondary) HStack { Text("Position in seconds");TextField("Seconds",value:$system.cueDraft,format:.number.precision(.fractionLength(2))).textFieldStyle(.roundedBorder).frame(width:100);Text("/ "+DJStyle.stamp(deck.player.duration)).foregroundStyle(.secondary) } Slider(value:$system.cueDraft,in:0...max(0.001,deck.player.duration)).accessibilityLabel("Cue point position") - Button("Use Current Playhead") { system.cueDraft = deck.player.time } + Button("Use Current Playhead") { system.cueDraft = deck.player.time }.help("Use the current playback position as the main cue") Text("Saving changes the cue point without moving or pausing playback. The CUE button pauses and returns to this position.").font(.caption).foregroundStyle(.secondary) - HStack { Button("Cancel") { system.cueEditing = false };Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction) } + HStack { Button("Cancel") { system.cueEditing = false }.help("Close without changing the cue");Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction).help("Save the main cue without moving or pausing playback") } }.padding(24).frame(width:470) } } @@ -2255,13 +2352,13 @@ private struct DJGraphicEQDialog: View { Label("DJ Graphic Equaliser",systemImage:"slider.vertical.3").font(.title2.bold()).foregroundStyle(DJStyle.a) Text("Shared by both decks and all tracks. Your deck EQ controls remain independent.").font(.callout).foregroundStyle(.secondary) HStack { - Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch) + Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch).help("Enable or bypass the shared ten-band equaliser") Spacer() Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) { ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) } if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") } }.frame(width:230) - Button("Reset") { system.chooseGraphicPreset("Flat") } + Button("Reset") { system.chooseGraphicPreset("Flat") }.help("Return every graphic EQ band to 0 dB") } HStack(alignment:.top,spacing:14) { ForEach(0..<10,id:\.self) { index in diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 05169d8..6296ea5 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -216,6 +216,10 @@ import CoreAudio loopIn = start;loopOut = end;loopEnabled = true } func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil } + func clearHotCues() { guard canJog else { return };hotCues = [nil,nil,nil,nil] } + func clearCue() { guard canJog else { return };cuePosition = 0 } + func clearDeckMarkers() { clearHotCues();clearCue();clearLoop() } + func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) } private var graphicsGeneration = UUID() private var graphicsTask: Task? diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 808ed60..a62a750 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2074,14 +2074,14 @@ extension KJamsCloneTests { XCTAssertEqual(Array(leds.prefix(9)),[0x90,0,127,0x90,1,1,0x90,2,127]) } @MainActor func testEQAndFilterDoubleClickActuallyResetKnobs() async throws { - var values = [6.0,-6.0,0.75] - let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<3,id:\.self) { i in DJKnob(label:["HIGH","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 2 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:240,height:80)) - host.frame = NSRect(x:0,y:0,width:240,height:80) + var values = [6.0,3.0,-6.0,0.75] + let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<4,id:\.self) { i in DJKnob(label:["HIGH","MID","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 3 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:320,height:80)) + host.frame = NSRect(x:0,y:0,width:320,height:80) let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.contentView = host let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey() defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() } try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded() - for index in 0..<3 { + for index in 0..<4 { for click in 1...2 { for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:40+index*80,y:46),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:click,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) @@ -2089,7 +2089,21 @@ extension KJamsCloneTests { } try await Task.sleep(nanoseconds:100_000_000) } - XCTAssertEqual(values,[0,0,0]) + XCTAssertEqual(values,[0,0,0,0]) + func controls(_ view: NSView) -> [DJRotaryControl] { (view as? DJRotaryControl).map{[$0]} ?? view.subviews.flatMap{controls($0)} } + let knobs = controls(host) + XCTAssertEqual(knobs.count,4) + for knob in knobs { + let centre = knob.convert(NSPoint(x:knob.bounds.midX,y:14),to:nil) + let down = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDown,location:centre,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:1)) + knob.mouseDown(with:down) + let drag = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDragged,location:NSPoint(x:centre.x,y:centre.y+12),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:1)) + knob.mouseDragged(with:drag);XCTAssertNotEqual(knob.value,0) + let label = knob.convert(NSPoint(x:knob.bounds.midX,y:35),to:nil) + let doubleClick = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDown,location:label,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:2,pressure:1)) + knob.mouseDown(with:doubleClick);XCTAssertEqual(knob.value,0) + } + XCTAssertEqual(values,[0,0,0,0],"Reset on the label also updates each audio parameter after a drag") } } @@ -2362,9 +2376,7 @@ extension KJamsCloneTests { let libraryFrame = libraryScroll.convert(libraryScroll.bounds,to:host) if host.isFlipped { XCTAssertGreaterThan(libraryFrame.maxY,host.bounds.maxY-50,"The library fills the bottom of the workspace") } else { XCTAssertLessThan(libraryFrame.minY,host.bounds.minY+50,"The library fills the bottom of the workspace") } - if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/dj-tab.png")) } try await selectTab(0) - if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/karaoke-tab.png")) } } } @@ -3234,6 +3246,16 @@ extension KJamsCloneTests { let window = NSWindow(contentRect:view.frame,styleMask:[.borderless],backing:.buffered,defer:false) window.contentView = view;window.appearance = NSAppearance(named:.darkAqua);window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil) try await Task.sleep(nanoseconds:250_000_000);view.layoutSubtreeIfNeeded() + if size.width == 1320 { + system.a.player.filter = 0.63;system.b.player.filter = -0.31 + try await Task.sleep(nanoseconds:60_000_000);view.layoutSubtreeIfNeeded() + func knobs(_ parent: NSView) -> [DJRotaryControl] { (parent as? DJRotaryControl).map{[$0]} ?? parent.subviews.flatMap{knobs($0)} } + let filters = knobs(view).filter{$0.label == "FILTER"} + XCTAssertEqual(filters.count,2) + XCTAssertTrue(filters.contains{abs($0.value-0.63) < 0.001});XCTAssertTrue(filters.contains{abs($0.value+0.31) < 0.001},"External audio-model changes update both mixer strips") + for filter in filters { filter.resetToDefault() } + XCTAssertEqual(system.a.player.filter,0);XCTAssertEqual(system.b.player.filter,0) + } let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds));view.cacheDisplay(in:view.bounds,to:rep) let png = try XCTUnwrap(rep.representation(using:.png,properties:[:])) let path = root.appendingPathComponent("DJ-Studio-\(Int(size.width)).png");try png.write(to:path) @@ -3246,3 +3268,29 @@ extension KJamsCloneTests { } } } + +extension KJamsCloneTests { + @MainActor func testClearingDeckMarkersPreservesLoadedPlayingAudio() async 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.wav")) + let player = Player(waveformCacheDirectory:root,persistsMix:false) + defer { player.stop() };player.autoFade = false + try player.load(Song(id:7,title:"Markers",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false) + player.setCue(at:0.4) + for index in 0..<4 { player.seek(0.2+Double(index)*0.1);player.hotCue(index) } + XCTAssertTrue(player.hotCues.allSatisfy{$0 != nil}) + player.hotCue(2,clear:true);XCTAssertNil(player.hotCues[2]);XCTAssertNotNil(player.hotCues[0]) + player.defineLoop(start:0.2,end:0.5) + for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:5_000_000) } + player.seek(0.25);player.toggle();try await Task.sleep(nanoseconds:80_000_000) + let position = player.displayTime + player.clearDeckMarkers() + XCTAssertEqual(player.currentSongID,7);XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted) + XCTAssertTrue(player.hotCues.allSatisfy{$0 == nil});XCTAssertEqual(player.cuePosition,0) + XCTAssertNil(player.loopIn);XCTAssertNil(player.loopOut);XCTAssertFalse(player.loopEnabled) + XCTAssertEqual(player.displayTime,position,accuracy:0.04,"Clearing markers must not jump back to the start") + try await Task.sleep(nanoseconds:160_000_000) + XCTAssertGreaterThan(player.displayTime,position+0.08) + } +} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 8f6d1b2..502b9db 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -136,3 +136,7 @@ Loop activation and cancellation prepare an idle transport node while the audibl DJ Studio’s performance workspace uses a teal/violet palette, separate track headers, full-width stacked scrolling waveforms, two deck performance panels and a central two-channel mixer. Each performance panel groups pitch/sync, platter, transport, hot cues and loop controls. Cue and loop editors are in the flag menu. The mixer groups both channels’ EQ/filter/level controls above the crossfader; master level is in the studio header. The performance/library divider and collection/table divider resize independently. The library sidebar provides All Music, Favourites, Top Rated (four or five stars), Unrated and Needs Metadata (missing artist), plus existing playlists. Sorting supports title, artist, album, duration, rating and original playlist/import order; use the arrow to reverse it. Playlist selection retains playlist order by default. Search, multiple selection, select-all, native column resizing, ratings, context actions, waveform jobs and paged scrolling remain available. Selecting a track reveals Info and Load A/B controls below the table. The collection sidebar scrolls and can be hidden from the header. All views use the existing independent DJ database and playlists; no external resources or third-party artwork are included. + +EQ and filter knobs use native mouse-event click counting, so double-clicking either the knob or its label restores its defined default (0 dB for EQ and centred filter). Dragging remains vertical; arrow keys adjust the focused control and Space resets it. Resize handle positions and collection-sidebar visibility are remembered between launches. + +The eraser menu beside IN/OUT clears individual hot cues, all hot cues, the main cue, loop markers, or every cue/loop marker on that deck. Hot cues also support Option-click and right-click to clear. Clearing the main cue resets it to track start without seeking; clearing markers retains the loaded track and playback. Hover descriptions cover the performance controls, mixer, library actions, queue controls and cue/loop editors.