removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-07 10:37:20 +00:00
parent a1c25cdb23
commit ec601f3544
6 changed files with 242 additions and 89 deletions
+8 -8
View File
@@ -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))
}
+3 -3
View File
@@ -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)
}
}
+168 -71
View File
@@ -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<Int64>) -> 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<Double>
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<Double>
init(value: Binding<Double>) { 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<Double> = -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<Double>
@@ -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
+4
View File
@@ -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<Void,Never>?