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) { 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 { Label("DJ Queue",systemImage:"text.line.first.and.arrowtriangle.forward").font(.headline);Spacer();Text("\(automix.entries.count)").foregroundStyle(.secondary) }
HStack { HStack {
Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor) 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) 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) }.controlSize(.small)
HStack { 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) }.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) } if let progress = automix.progress { ProgressView(value:progress) }
List { List {
ForEach(automix.entries) { entry in ForEach(automix.entries) { entry in
HStack(spacing:5) { HStack(spacing:5) {
VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
Spacer(minLength:0) 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.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") 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") 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) }.font(.caption).buttonStyle(.borderless)
} }
if automix.entries.isEmpty { Text("Add songs with + or drag them here from the DJ library.").font(.caption).foregroundStyle(.secondary) } 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 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) Text(automix.message).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
}.padding(12).background(Color.white.opacity(0.035),in:RoundedRectangle(cornerRadius:6)) }.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 { var body: some View {
VStack(alignment:.leading,spacing:18) { VStack(alignment:.leading,spacing:18) {
Text("Deck \(letter) · Loop Points").font(.title2.bold()) 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("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 } } 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(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) 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) }.padding(24).frame(width:540)
} }
} }
+168 -71
View File
@@ -981,7 +981,7 @@ private struct DJQueueButton: View {
let open: () -> Void let open: () -> Void
var body: some View { var body: some View {
Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") } 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" case all = "All Music",favourites = "Favourites",rated = "Top Rated",unrated = "Unrated",missingArtist = "Needs Metadata"
var id: String { rawValue } 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" } } 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 { enum DJLibrarySort: String,CaseIterable,Identifiable {
case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order" case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order"
@@ -1016,13 +1025,7 @@ enum DJLibraryBrowser {
else { source = songs } else { source = songs }
let filtered = source.filter { song in let filtered = source.filter { song in
guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false } guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false }
switch collection { return collection.includes(song,favourites:favourites)
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
}
} }
guard sort != .source else { guard sort != .source else {
let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered
@@ -1079,7 +1082,7 @@ struct DJConsole: View {
libraryRevision = UUID() libraryRevision = UUID()
filteredTracks = DJLibraryBrowser.rows(system.songs,collection:collection,favourites:system.favourites,playlist:playlist == nil ? nil : playlistIDs,query:query,sort:librarySort,ascending:ascending) 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 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))) selection.formIntersection(Set(filteredTracks.map(\.id)))
page = min(page,max(0,(filteredTracks.count+49)/50-1)) page = min(page,max(0,(filteredTracks.count+49)/50-1))
@@ -1112,7 +1115,7 @@ struct DJConsole: View {
.onTapGesture(count:2) { performanceHeight = 454 } .onTapGesture(count:2) { performanceHeight = 454 }
.gesture(DragGesture(coordinateSpace:.global).onChanged { value in .gesture(DragGesture(coordinateSpace:.global).onChanged { value in
if performanceDragStart == nil { performanceDragStart = performanceSize(geometry.size.height) } 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 }) }.onEnded { _ in performanceDragStart = nil })
.accessibilityLabel("Resize DJ performance area") .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) 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) 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) } 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() 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 { 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}) 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) }.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 } 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 { private func collectionRow(_ name: String,icon: String,count: Int?,active: Bool,action: @escaping () -> Void) -> some View {
Button(action:action) { 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)) 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 { private var browser: some View {
GeometryReader { geometry in GeometryReader { geometry in
@@ -1254,26 +1257,26 @@ struct DJConsole: View {
HStack(spacing:12) { 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) } 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) Spacer(minLength:8)
StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340) 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)) 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) }.padding(14)
HStack(spacing:10) { 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") Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.buttonStyle(.plain).help("Reverse sort order")
LibrarySelectionBadge(count:selection.count) LibrarySelectionBadge(count:selection.count)
Spacer(minLength:4) 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) { 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() 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) }.buttonStyle(.plain)
}.controlSize(.small).padding(.horizontal,14).padding(.bottom,10) }.controlSize(.small).padding(.horizontal,14).padding(.bottom,10)
Divider().overlay(Color.white.opacity(0.05)) Divider().overlay(Color.white.opacity(0.05))
ScrollViewReader { reader in ScrollViewReader { reader in
Table(visible,selection:$selection) { Table(visible,selection:$selection) {
TableColumn("Fav") { song in 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) }.width(38)
TableColumn("Title") { song in 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()}) 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) 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) } 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() Spacer()
Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)) 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)) 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)) 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) }.padding(10).background(DJStyle.background)
} }
}.frame(minWidth:530,maxWidth:.infinity,maxHeight:.infinity).background(DJStyle.panel) }.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("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") 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) 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) }.frame(width:62).disabled(!player.canJog)
} }
var tonePanel: some View { var tonePanel: some View {
@@ -1417,7 +1420,7 @@ private struct DJDeckSurfaces {
HStack(spacing:3) { HStack(spacing:3) {
VStack(spacing:5) { 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) } 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) 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) 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 let label: String
@Binding var value: Double @Binding var value: Double
let range: ClosedRange<Double> let range: ClosedRange<Double>
let reset: Double let reset: Double
let color: Color let color: Color
var format: (Double) -> String = {String(format:"%+.0f dB",$0)} var format: (Double) -> String = {String(format:"%+.0f dB",$0)}
@State private var initial: Double? func makeCoordinator() -> Coordinator { Coordinator(value:$value) }
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } func makeNSView(context: Context) -> DJRotaryControl {
var body: some View { let view = DJRotaryControl();view.changed = { [weak coordinator = context.coordinator] value in coordinator?.value.wrappedValue = value };return view
VStack(spacing:1) { }
ZStack { func updateNSView(_ view: DJRotaryControl,context: Context) {
Circle().fill(Color.black.opacity(0.35)).overlay { Circle().stroke(.white.opacity(0.12),lineWidth:2) } context.coordinator.value = $value
Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90)) view.label = label;view.value = value;view.range = range;view.defaultValue = reset;view.colour = NSColor(color);view.isEnabled = context.environment.isEnabled
Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270)) view.setAccessibilityElement(true);view.setAccessibilityRole(.slider);view.setAccessibilityLabel(label);view.setAccessibilityValue(format(value))
}.frame(width:29,height:29).contentShape(Circle()) view.setAccessibilityCustomActions([NSAccessibilityCustomAction(name:"Reset to default",handler:{[weak view] in view?.resetToDefault();return true})])
.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}) view.toolTip = "\(label): \(format(value)). Drag vertically; double-click to reset."
Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary) view.needsDisplay = true
}.frame(minWidth:36).contentShape(Rectangle()) }
.highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil }) func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJRotaryControl,context: Context) -> CGSize? { CGSize(width:36,height:40) }
.accessibilityAction(named:Text("Reset to default")) { value = reset;initial = nil } final class Coordinator {
.help("\(label): \(format(value)). Drag vertically; double-click to reset.") var value: Binding<Double>
.accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value)) init(value: Binding<Double>) { self.value = value }
.accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) } }
}
/// 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 { private struct DJPlatter: NSViewRepresentable {
@@ -1623,11 +1672,11 @@ struct DJHardwareFader: NSViewRepresentable {
slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical
slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value
slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false 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) slider.setContentHuggingPriority(.defaultLow,for:.horizontal)
return slider 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)) } 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 { final class Coordinator: NSObject {
var value: Binding<Double> var value: Binding<Double>
@@ -1694,9 +1743,9 @@ struct DJContinuousConsole: View {
VStack(spacing:8) { VStack(spacing:8) {
HStack { Text("CHANNEL MIXER");Spacer();Text(system.routing.mode.uppercased()).foregroundStyle(.secondary) }.font(.system(size:8,weight:.bold)).tracking(1) 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) { 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) 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)) }.font(.system(size:9,weight:.bold))
HStack(spacing:8) { HStack(spacing:8) {
Text("A").foregroundStyle(DJStyle.a) 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 { private struct DJTrackHeader: View {
@ObservedObject var deck: DJChannel @ObservedObject var deck: DJChannel
@ObservedObject var player: Player @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) Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1)
HStack(spacing:6) { HStack(spacing:6) {
Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1) 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) 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) }.font(.system(size:10)).disabled(!player.canJog)
} }
Spacer(minLength:4) Spacer(minLength:4)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()) Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()).help("Track time remaining")
Button("Load",action:load).controlSize(.mini) 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 } if deck.letter == "B" { artwork }
}.padding(10).frame(maxWidth:.infinity) }.padding(10).frame(maxWidth:.infinity)
@@ -1772,37 +1832,74 @@ private struct DJConsoleTransport: View {
@ObservedObject var player: Player @ObservedObject var player: Player
@ObservedObject var system: DJSystem @ObservedObject var system: DJSystem
private var playButton: some View { 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 { private var loopButtons: some View {
HStack(spacing:0) { HStack(spacing:0) {
Button("‹") { deck.loopBeats = max(1,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 beat loop; requires confirmed BPM") 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) } 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 { var body: some View {
VStack(spacing:6) { VStack(spacing:6) {
HStack(spacing:8) { HStack(spacing:8) { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }.controlSize(.regular)
playButton;cueButton;loopButtons;headphones;Spacer(minLength:0)
}.controlSize(.regular)
HStack(spacing:4) { 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 { Menu {
Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) } Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) }
Button("Set Cue Here") { player.setCue() } Button("Set Cue Here") { player.setCue() }
Button("Clear Main Cue") { player.clearCue() }
Button("Edit Loop…") { system.loopEditor = deck.letter } Button("Edit Loop…") { system.loopEditor = deck.letter }
} label: { Image(systemName:"flag") } } label: { Image(systemName:"flag") }.help("Set, edit or clear the main cue; open the loop start/end editor")
Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() } 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.")
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") } .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) Spacer(minLength:0)
}.controlSize(.mini) }.controlSize(.mini)
}.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog) }.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog)
} }
} }
private struct DJCentreWaveform: View { private struct DJCentreWaveform: View {
@ObservedObject var deck: DJChannel @ObservedObject var deck: DJChannel
@ObservedObject var player: Player @ObservedObject var player: Player
@@ -2100,9 +2197,9 @@ private struct DJCueEditor: View {
Text(deck.song?.title ?? "No track").foregroundStyle(.secondary) 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) } 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") 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) 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) }.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) 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) Text("Shared by both decks and all tracks. Your deck EQ controls remain independent.").font(.callout).foregroundStyle(.secondary)
HStack { 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() Spacer()
Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) { Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) {
ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) } ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) }
if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") } if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") }
}.frame(width:230) }.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) { HStack(alignment:.top,spacing:14) {
ForEach(0..<10,id:\.self) { index in ForEach(0..<10,id:\.self) { index in
+4
View File
@@ -216,6 +216,10 @@ import CoreAudio
loopIn = start;loopOut = end;loopEnabled = true loopIn = start;loopOut = end;loopEnabled = true
} }
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil } 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) } func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) }
private var graphicsGeneration = UUID() private var graphicsGeneration = UUID()
private var graphicsTask: Task<Void,Never>? private var graphicsTask: Task<Void,Never>?
+55 -7
View File
@@ -2074,14 +2074,14 @@ extension KJamsCloneTests {
XCTAssertEqual(Array(leds.prefix(9)),[0x90,0,127,0x90,1,1,0x90,2,127]) XCTAssertEqual(Array(leds.prefix(9)),[0x90,0,127,0x90,1,1,0x90,2,127])
} }
@MainActor func testEQAndFilterDoubleClickActuallyResetKnobs() async throws { @MainActor func testEQAndFilterDoubleClickActuallyResetKnobs() async throws {
var values = [6.0,-6.0,0.75] var values = [6.0,3.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)) 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:240,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 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() 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() } defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded() 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 click in 1...2 {
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { 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) 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) 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) 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") } 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") } 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) 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) 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) 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() 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 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 png = try XCTUnwrap(rep.representation(using:.png,properties:[:]))
let path = root.appendingPathComponent("DJ-Studio-\(Int(size.width)).png");try png.write(to:path) 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)
}
}
+4
View File
@@ -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. 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. 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.