Compare commits

9 changed files with 655 additions and 213 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)
} }
} }
+281 -152
View File
@@ -373,6 +373,7 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var importLogVisible = false @Published var importLogVisible = false
@Published private(set) var importing = false @Published private(set) var importing = false
private var importTask: Task<Void,Never>? private var importTask: Task<Void,Never>?
private var importGeneration = UUID()
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]() private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
init(storageURL: URL? = nil) { init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ") root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
@@ -418,6 +419,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) { func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return } guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true importing = true;importProgress = .init();importLogVisible = true
let importID = UUID();importGeneration = importID
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id} let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()} let access = urls.filter{$0.startAccessingSecurityScopedResource()}
@@ -433,15 +435,15 @@ struct DJAudioDevice: Identifiable, Equatable {
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true) _ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
} }
} }
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,continueOnUnreadableAudio:true).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
latest = value latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } } Task { @MainActor [weak self] in if self?.importing == true,self?.importGeneration == importID { self?.importProgress = value } }
} }
} catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" } } catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" } catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest,loadedIDs = importedIDs latest.finished = true;let result = latest,loadedIDs = importedIDs
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result guard let self,self.importGeneration == importID else { return };self.importing = false;self.importTask = nil;self.importProgress = result
self.lastImportReport = result self.lastImportReport = result
self.perform { try self.refresh() } self.perform { try self.refresh() }
if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } } if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } }
@@ -451,7 +453,7 @@ struct DJAudioDevice: Identifiable, Equatable {
} }
} }
} }
func cancelImport() { queuedFileLoads = [];importTask?.cancel() } func cancelImport() { queuedFileLoads = [];importTask?.cancel();if importing { importProgress?.current = "Cancelling…" } }
func continueFileLoads() { func continueFileLoads() {
guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return } guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return }
let next = queuedFileLoads.removeFirst() let next = queuedFileLoads.removeFirst()
@@ -981,7 +983,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 +1006,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,15 +1027,12 @@ 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) guard sort != .source else {
case .rated:return song.rating >= 4 let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered
case .unrated:return song.rating == 0 return ascending ? ordered : Array(ordered.reversed())
case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty
}
} }
guard sort != .source else { return ascending ? filtered : Array(filtered.reversed()) }
return filtered.sorted { lhs,rhs in return filtered.sorted { lhs,rhs in
let comparison: ComparisonResult let comparison: ComparisonResult
switch sort { switch sort {
@@ -1055,6 +1063,10 @@ struct DJConsole: View {
@State private var ascending = true @State private var ascending = true
@State private var collectionCounts = [DJCollection:Int]() @State private var collectionCounts = [DJCollection:Int]()
@AppStorage("DJStudio.librarySidebar") private var sidebarVisible = true @AppStorage("DJStudio.librarySidebar") private var sidebarVisible = true
@AppStorage("DJStudio.performanceHeight") private var performanceHeight = 454.0
@AppStorage("DJStudio.sidebarWidth") private var sidebarWidth = 184.0
@State private var performanceDragStart: Double?
@State private var sidebarDragStart: Double?
@State private var queueVisible = false @State private var queueVisible = false
@State private var query = "" @State private var query = ""
@State private var playlist: Int64? @State private var playlist: Int64?
@@ -1072,7 +1084,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))
@@ -1097,22 +1109,21 @@ struct DJConsole: View {
var body: some View { var body: some View {
VStack(spacing:10) { VStack(spacing:10) {
header header
VSplitView { GeometryReader { geometry in
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(minHeight:408,idealHeight:438,maxHeight:560) VStack(spacing:0) {
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(minHeight:210,maxHeight:.infinity) DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(height:performanceSize(geometry.size.height))
RoundedRectangle(cornerRadius:2).fill(Color.white.opacity(0.16)).frame(width:44,height:3).frame(maxWidth:.infinity).frame(height:10).contentShape(Rectangle())
.help("Drag to resize the performance area; double-click to reset")
.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),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)
} }
if let progress = system.automaticWaveformProgress {
BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil})
} }
if let progress = system.importProgress { }.padding(14).frame(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top)
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
Spacer()
if system.importing { ProgressView().controlSize(.mini);Button("View Progress") { system.importLogVisible = true };Button("Cancel") { system.cancelImport() } }
else { Button("View Log") { system.importLogVisible = true };Button("Dismiss") { system.importProgress = nil } }
}.font(.caption).controlSize(.mini)
}
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top)
.background(DJStyle.background).foregroundStyle(.white) .background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in .alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in
@@ -1121,7 +1132,7 @@ struct DJConsole: View {
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") } } message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
.sheet(isPresented:$system.importLogVisible) { .sheet(isPresented:$system.importLogVisible) {
if let progress = system.importing ? system.importProgress : system.lastImportReport { if let progress = system.importing ? system.importProgress : system.lastImportReport {
ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false }) ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false },waveformStatus:system.automaticWaveformProgress.map { progress in BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) })
.interactiveDismissDisabled(system.importing) .interactiveDismissDisabled(system.importing)
} }
} }
@@ -1174,29 +1185,33 @@ struct DJConsole: View {
.onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 } .onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() } .onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() }
.onStudioChange(of:sidebarWidth) { _ in libraryRevision = UUID() }
.onStudioChange(of:system.favourites) { _ in updateFilteredTracks() } .onStudioChange(of:system.favourites) { _ in updateFilteredTracks() }
} }
private func performanceSize(_ available: Double) -> Double { max(0,min(max(418,performanceHeight),min(580,max(0,available-220)))) }
private var header: some View { private var header: some View {
HStack(spacing:14) { HStack(spacing:14) {
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).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 { system.importLogVisible = true } label: { Image(systemName:"doc.text") }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).disabled(system.importProgress == nil).help("Open the import log and background waveform progress")
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 }
private func chooseCollection(_ value: DJCollection) { playlist = nil;collection = value;librarySort = .title;ascending = true;refreshPlaylist();page = 0 } private func chooseCollection(_ value: DJCollection) { playlist = nil;collection = value;librarySort = .title;ascending = true;refreshPlaylist();page = 0 }
private var collectionSidebar: some View { private var collectionSidebar: some View {
ScrollView {
VStack(alignment:.leading,spacing:0) { VStack(alignment:.leading,spacing:0) {
Text("COLLECTION").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary).padding(.horizontal,16).padding(.top,18).padding(.bottom,10) Text("COLLECTION").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary).padding(.horizontal,16).padding(.top,18).padding(.bottom,10)
ForEach(DJCollection.allCases) { scope in ForEach(DJCollection.allCases) { scope in
collectionRow(scope.rawValue,icon:scope.icon,count:collectionCounts[scope] ?? 0,active:playlist == nil && collection == scope) { chooseCollection(scope) } collectionRow(scope.rawValue,icon:scope.icon,count:collectionCounts[scope] ?? 0,active:playlist == nil && collection == scope) { chooseCollection(scope) }
} }
HStack { Text("PLAYLISTS").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary);Spacer();Button { renaming = false;playlistName = "";playlistEditor = true } label: { Image(systemName:"plus") }.buttonStyle(.plain).help("Create playlist") }.padding(.horizontal,16).padding(.top,24).padding(.bottom,8) HStack { Text("PLAYLISTS").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary);Spacer();Button { renaming = false;playlistName = "";playlistEditor = true } label: { Image(systemName:"plus") }.buttonStyle(.plain).help("Create playlist") }.padding(.horizontal,16).padding(.top,24).padding(.bottom,8)
ScrollView {
VStack(spacing:2) { VStack(spacing:2) {
ForEach(system.playlists) { list in ForEach(system.playlists) { list in
collectionRow(list.name,icon:"music.note",count:nil,active:playlist == list.id) { collection = .all;playlist = list.id;librarySort = .source;ascending = true;refreshPlaylist();page = 0 } collectionRow(list.name,icon:"music.note",count:nil,active:playlist == list.id) { collection = .all;playlist = list.id;librarySort = .source;ascending = true;refreshPlaylist();page = 0 }
@@ -1206,60 +1221,65 @@ struct DJConsole: View {
} }
} }
} }
}
if system.playlists.isEmpty { Text("Build your next set with playlists.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,16).padding(.bottom,18) } if system.playlists.isEmpty { Text("Build your next set with playlists.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,16).padding(.bottom,18) }
Spacer(minLength:0) Spacer(minLength:0)
}.frame(minWidth:156,idealWidth:184,maxWidth:250,maxHeight:.infinity).background(DJStyle.background) }.padding(.bottom,12)
}.frame(minWidth:156,idealWidth:176,maxWidth:210,maxHeight:.infinity).background(DJStyle.background)
} }
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 {
HSplitView { GeometryReader { geometry in
if sidebarVisible { collectionSidebar } HStack(spacing:0) {
if sidebarVisible {
collectionSidebar.frame(width:min(max(156,sidebarWidth),210))
Rectangle().fill(Color.white.opacity(0.07)).frame(width:6).contentShape(Rectangle())
.help("Drag to resize collections; double-click to reset")
.onTapGesture(count:2) { sidebarWidth = 184 }
.gesture(DragGesture(coordinateSpace:.global).onChanged { value in
if sidebarDragStart == nil { sidebarDragStart = sidebarWidth }
sidebarWidth = min(210,max(156,(sidebarDragStart ?? 184)+value.translation.width))
}.onEnded { _ in sidebarDragStart = nil })
.accessibilityLabel("Resize DJ collection sidebar")
}
VStack(spacing:0) { VStack(spacing:0) {
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("Favourite") { 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(55) }.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()})
.frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight) .frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight)
}.width(min:180,ideal:400,max:1600) }.width(min:150,ideal:260,max:1200)
TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:100,ideal:200,max:800) TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:90,ideal:150,max:800)
TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:90,ideal:180,max:800) TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:80,ideal:120,max:800)
TableColumn("Time") { song in Text(DJStyle.stamp(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(min:45,ideal:60,max:100) TableColumn("Time") { song in Text(DJStyle.stamp(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(min:45,ideal:60,max:100)
TableColumn("Rating") { song in TableColumn("Rating") { song in
GoldRating(rating:Binding(get:{song.rating},set:{value in system.perform { try system.library?.rate(song.id,value);try system.refresh() } })) GoldRating(rating:Binding(get:{song.rating},set:{value in system.perform { try system.library?.rate(song.id,value);try system.refresh() } }))
}.width(min:95,ideal:105,max:140) }.width(min:95,ideal:105,max:140)
TableColumn("Load") { song in
HStack(spacing:7) {
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
}.buttonStyle(.bordered).controlSize(.small)
}.width(90)
}.font(.system(size:system.settings.libraryFont)) }.font(.system(size:system.settings.libraryFont))
.contextMenu(forSelectionType:Int64.self) { ids in .contextMenu(forSelectionType:Int64.self) { ids in
if let song = songs.first(where:{ids.contains($0.id)}) { playlistMenu(song) } if let song = songs.first(where:{ids.contains($0.id)}) { playlistMenu(song) }
@@ -1278,12 +1298,13 @@ 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)
}
}.clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(.white.opacity(0.07))) }.clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(.white.opacity(0.07)))
} }
@ViewBuilder private func playlistMenu(_ song: Song) -> some View { @ViewBuilder private func playlistMenu(_ song: Song) -> some View {
@@ -1372,12 +1393,11 @@ private struct DJDeckSurfaces {
@ObservedObject var system: DJSystem @ObservedObject var system: DJSystem
private var color: Color { DJStyle.color(deck.letter) } private var color: Color { DJStyle.color(deck.letter) }
var tempoStrip: some View { var tempoStrip: some View {
VStack(spacing:5) { VStack(spacing:3) {
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")
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : "—").font(.system(size:11,weight:.semibold).monospacedDigit())
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:52,height:94) 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 {
@@ -1387,13 +1407,13 @@ private struct DJDeckSurfaces {
DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color) DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color)
DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color) DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color)
} }
VStack(spacing:12) { VStack(spacing:8) {
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)}) DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
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:94) 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)
Button { player.vocalReductionEnabled.toggle() } label: { Image(systemName:player.vocalReductionEnabled ? "mic.slash.fill" : "mic.fill") }.buttonStyle(.plain).foregroundStyle(player.vocalReductionEnabled ? color : .secondary).help("Centre vocal reduction: reduces centred stereo audio, including some instruments; mono tracks pass through").accessibilityLabel("Deck \(deck.letter) centre vocal reduction").accessibilityValue(player.vocalReductionEnabled ? "On" : "Off") Button { player.vocalReductionEnabled.toggle() } label: { Image(systemName:player.vocalReductionEnabled ? "mic.slash.fill" : "mic.fill") }.buttonStyle(.plain).foregroundStyle(player.vocalReductionEnabled ? color : .secondary).help("Centre vocal reduction: reduces centred stereo audio, including some instruments; mono tracks pass through").accessibilityLabel("Deck \(deck.letter) centre vocal reduction").accessibilityValue(player.vocalReductionEnabled ? "On" : "Off")
@@ -1402,30 +1422,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 {
@@ -1598,11 +1664,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>
@@ -1639,33 +1705,59 @@ struct DJContinuousConsole: View {
let loadA: () -> Void let loadA: () -> Void
let loadB: () -> Void let loadB: () -> Void
var body: some View { var body: some View {
VStack(spacing:0) { VStack(spacing:8) {
HStack(spacing:0) { HStack(spacing:10) {
DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) } DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10))
Divider() DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10))
DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) } }.frame(height:82)
}.frame(height:92).background(Color.black.opacity(0.4)) VStack(spacing:2) {
Divider() DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) }
HStack(spacing:0) { DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:344) }.frame(minHeight:64,maxHeight:.infinity).background(.black).clipShape(RoundedRectangle(cornerRadius:8)).clipped()
Divider() HStack(alignment:.top,spacing:10) {
VStack(spacing:1) { performance(system.a)
DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) } mixer.frame(width:292)
DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) } performance(system.b)
}.frame(maxWidth:.infinity).background(.black) }.frame(height:252)
Divider() }.accessibilityIdentifier("dj.performance.workspace")
DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:344) }
}.frame(height:198).background(Color(white:0.12)) private func performance(_ deck: DJChannel) -> some View {
Divider() VStack(spacing:6) {
HStack(spacing:12) { HStack { Text("DECK \(deck.letter)").foregroundStyle(DJStyle.color(deck.letter));Spacer();Text("PERFORMANCE").foregroundStyle(.secondary) }.font(.system(size:9,weight:.bold)).tracking(1.2)
DJConsoleTransport(deck:system.a,player:system.a.player,system:system) DJDeckDropZone(system:system,letter:deck.letter) { DJConsoleWing(deck:deck,player:deck.player,system:system) }
VStack(spacing:0) { Divider().overlay(.white.opacity(0.04))
DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36).disabled(system.routing.mode == "External") DJConsoleTransport(deck:deck,player:deck.player,system:system)
HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("Centre") { system.crossfader = 0.5 }.buttonStyle(.plain);Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.system(size:9)) }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top)
}.frame(width:195) .background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
DJConsoleTransport(deck:system.b,player:system.b.player,system:system) .overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(DJStyle.color(deck.letter)).frame(height:2).padding(.horizontal,14) }
}.padding(.horizontal,12).padding(.vertical,10).background(Color(white:0.14)) }
}.overlay(Rectangle().stroke(.white.opacity(0.09))) private var mixer: some 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) {
DJMixerChannel(deck:system.a,player:system.a.player,system:system)
Divider().frame(height:152)
DJMixerChannel(deck:system.b,player:system.b.player,system:system)
}.font(.system(size:9,weight:.bold))
HStack(spacing:8) {
Text("A").foregroundStyle(DJStyle.a)
DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:30).disabled(system.routing.mode == "External")
Text("B").foregroundStyle(DJStyle.b)
Button { system.crossfader = 0.5 } label: { Image(systemName:"scope") }.buttonStyle(.plain).help("Centre crossfader")
}.font(.system(size:10,weight:.semibold))
}.padding(12).frame(maxHeight:.infinity,alignment:.top).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
}
}
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 {
@@ -1683,16 +1775,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)
@@ -1712,57 +1804,94 @@ private struct DJConsoleWing: View {
@ObservedObject var system: DJSystem @ObservedObject var system: DJSystem
var body: some View { var body: some View {
let face = DJDeckSurfaces(deck:deck,player:player,system:system) let face = DJDeckSurfaces(deck:deck,player:player,system:system)
HStack(spacing:10) { HStack(spacing:18) {
if deck.letter == "B" { face.tonePanel } face.tempoStrip
if deck.letter == "A" { face.tempoStrip } Spacer(minLength:0)
ZStack { ZStack {
DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:134,height:134) DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:112,height:112)
VStack(spacing:3) { VStack(spacing:3) {
Text(String(format:"%+.1f%%",(player.tempo-1)*100)).font(.system(size:9)).foregroundStyle(.secondary) Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:19,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter))
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:21,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter))
Divider().frame(width:62)
Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:11).monospacedDigit()) }.frame(width:76,height:76).background(DJStyle.background,in:Circle()).allowsHitTesting(false)
}.frame(width:95,height:95).background(Color(white:0.095),in:Circle()).allowsHitTesting(false)
} }
if deck.letter == "A" { face.tonePanel } Spacer(minLength:0)
if deck.letter == "B" { face.tempoStrip } }.frame(maxWidth:.infinity).frame(height:130).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}.padding(.horizontal,9).frame(width:344).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
} }
} }
private struct DJConsoleTransport: View { private struct DJConsoleTransport: View {
@ObservedObject var deck: DJChannel @ObservedObject var deck: DJChannel
@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)
if deck.letter == "A" { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) } HStack(spacing:4) {
else { Spacer(minLength:0);headphones;loopButtons;cueButton;playButton } ForEach(0..<4) { index in
}.controlSize(.regular) Button("\(index+1)") {
HStack(spacing:6) { if NSEvent.modifierFlags.contains(.option) { player.hotCue(index,clear:true) }
if deck.letter == "B" { Spacer(minLength:0) } else { system.hotCue(index,deck:deck) }
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) } } } }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter))
Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() };Button("Loop ✎") { system.loopEditor = 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.")
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 Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) }
if deck.letter == "A" { Spacer(minLength:0) } }
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") }.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) }.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
@@ -1935,7 +2064,7 @@ private struct DJConsoleButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var enabled @Environment(\.isEnabled) private var enabled
init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active } init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active }
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label.padding(.horizontal,size == .mini ? 6 : 12).padding(.vertical,size == .mini ? 3 : 7) configuration.label.padding(.horizontal,size == .mini ? 5 : 7).padding(.vertical,size == .mini ? 3 : 6)
.background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5)) .background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5))
.overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2)) .overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2))
.opacity(enabled ? 1 : 0.35) .opacity(enabled ? 1 : 0.35)
@@ -2060,9 +2189,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)
} }
} }
@@ -2215,13 +2344,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
+63 -14
View File
@@ -26,6 +26,9 @@ struct Importer {
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"] var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true var includeGraphics = true
var waveformCache: URL? = nil var waveformCache: URL? = nil
var continueOnUnreadableAudio = false
var metadataTimeout: TimeInterval = 2
var metadataLoader: (@Sendable (URL) async throws -> [AVMetadataKey:String])? = nil
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] { @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
if copyMedia { try ManagedMediaStorage.prepare(media) } if copyMedia { try ManagedMediaStorage.prepare(media) }
var state = ImportProgress() var state = ImportProgress()
@@ -73,6 +76,7 @@ struct Importer {
} }
} else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) { } else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) {
discovered.insert(url) discovered.insert(url)
report("Scanning · \(discovered.count.formatted()) supported files found · \(url.lastPathComponent)")
} }
} catch is CancellationError { throw CancellationError() } } catch is CancellationError { throw CancellationError() }
catch { throw LibraryError.message("Could not scan \(url.path): \(error.localizedDescription)") } catch { throw LibraryError.message("Could not scan \(url.path): \(error.localizedDescription)") }
@@ -85,7 +89,10 @@ struct Importer {
// Older libraries may contain indexed tracks without stored fingerprints. // Older libraries may contain indexed tracks without stored fingerprints.
// Compare only same-size candidates, and hash each legacy file at most once. // Compare only same-size candidates, and hash each legacy file at most once.
var legacyBySize = [UInt64:[Song]]() var legacyBySize = [UInt64:[Song]]()
var existingChecked = 0
for song in try library.songs() where song.fingerprint.isEmpty { for song in try library.songs() where song.fingerprint.isEmpty {
if cancelled() { throw CancellationError() }
existingChecked += 1;report("Checking existing library · \(existingChecked.formatted()) tracks · \(song.title)")
if let info = try? FileManager.default.attributesOfItem(atPath:song.audio),let size = info[.size] as? NSNumber,size.uint64Value > 0 { if let info = try? FileManager.default.attributesOfItem(atPath:song.audio),let size = info[.size] as? NSNumber,size.uint64Value > 0 {
legacyBySize[size.uint64Value,default:[]].append(song) legacyBySize[size.uint64Value,default:[]].append(song)
} }
@@ -222,9 +229,24 @@ struct Importer {
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)") state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)")
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
} }
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) } let hash: String
let hash = try audioFingerprint(audio,cancelled:cancelled) let graphicsHash: String?
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) } do {
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
report("Checking audio: "+audio.lastPathComponent)
if continueOnUnreadableAudio { _ = try AVAudioFile(forReading:audio) }
hash = try audioFingerprint(audio,cancelled:cancelled,progress:{ bytes in
report("Reading "+audio.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file))
})
graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
} catch is CancellationError { throw CancellationError() }
catch {
guard continueOnUnreadableAudio else { throw error }
state.completed += 1;state.skipped += 1
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (unreadable audio: "+error.localizedDescription+")")
report("Skipped unreadable file: "+audio.lastPathComponent,force:state.completed == 1)
continue
}
let duplicate = try duplicateWithFingerprint(hash,audio:audio) let duplicate = try duplicateWithFingerprint(hash,audio:audio)
var sameGraphics = cdg == nil var sameGraphics = cdg == nil
if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) { if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) {
@@ -238,7 +260,10 @@ struct Importer {
report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue
} }
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
if copy { try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled) } if copy {
report("Copying: "+audio.lastPathComponent)
try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled)
}
var graphics: String? var graphics: String?
if let cdg { if let cdg {
let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL
@@ -246,12 +271,25 @@ struct Importer {
graphics = target.path graphics = target.path
} }
imported += 1 imported += 1
report("Reading tags: "+audio.lastPathComponent,force:imported == 1)
let metadata = try audioMetadata(audio,cancelled:cancelled) let metadata = try audioMetadata(audio,cancelled:cancelled)
func tag(_ key: AVMetadataKey) -> String { metadata[key] ?? "" } if metadata == nil { state.logLines.append("[WARNING] "+sourcePath(audio)+" (tag read timed out; imported using its filename)") }
func tag(_ key: AVMetadataKey) -> String { metadata?[key] ?? "" }
let taggedTitle = tag(.commonKeyTitle) let taggedTitle = tag(.commonKeyTitle)
let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy) let duration = audioDuration(audio)
report("Saving track: "+audio.lastPathComponent)
try library.execute("SAVEPOINT kjmix_import_track")
let id: Int64
do {
id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:duration,fingerprint:hash,preferIncomingFile:copy)
try remember(audio,signature,id)
try library.execute("RELEASE SAVEPOINT kjmix_import_track")
} catch {
try? library.execute("ROLLBACK TO SAVEPOINT kjmix_import_track")
try? library.execute("RELEASE SAVEPOINT kjmix_import_track")
throw error
}
songIDs.insert(id) songIDs.insert(id)
try remember(audio,signature,id)
state.completed += 1 state.completed += 1
state.logLines.append("[IMPORTED] "+sourcePath(audio)) state.logLines.append("[IMPORTED] "+sourcePath(audio))
if let waveformCache { if let waveformCache {
@@ -274,29 +312,40 @@ struct Importer {
state.finished = true;report("Import complete",force:true) state.finished = true;report("Import complete",force:true)
return Array(songIDs) return Array(songIDs)
} }
private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String { private func audioFingerprint(_ url: URL,cancelled: () -> Bool,progress: (Int64) -> Void = {_ in}) throws -> String {
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() } let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
var hasher = SHA256() var hasher = SHA256()
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) } var bytes: Int64 = 0
while true {
if cancelled() { throw CancellationError() }
guard let data = try file.read(upToCount:1_048_576),!data.isEmpty else { break }
hasher.update(data:data);bytes += Int64(data.count);progress(bytes)
}
return hasher.finalize().map { String(format:"%02x",$0) }.joined() return hasher.finalize().map { String(format:"%02x",$0) }.joined()
} }
/// The importer runs on its worker queue. Bridge asynchronous tag loading here /// The importer runs on its worker queue. Bridge asynchronous tag loading here
/// so the existing synchronous transaction can retain its cancellation semantics. /// so the existing synchronous transaction can retain its cancellation semantics.
private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String] { private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String]? {
if cancelled() { throw CancellationError() }
let result = ImportMetadataResult(),ready = DispatchSemaphore(value:0) let result = ImportMetadataResult(),ready = DispatchSemaphore(value:0)
let asset = AVURLAsset(url:url),loader = metadataLoader
let task = Task.detached(priority:.utility) { let task = Task.detached(priority:.utility) {
defer { ready.signal() }
if let loader { result.set((try? await loader(url)) ?? [:]);return }
var tags = [AVMetadataKey:String]() var tags = [AVMetadataKey:String]()
let items = (try? await AVURLAsset(url:url).load(.commonMetadata)) ?? [] let items = (try? await asset.load(.commonMetadata)) ?? []
for item in items { for item in items {
if Task.isCancelled { break } if Task.isCancelled { break }
guard let key = item.commonKey,[AVMetadataKey.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher].contains(key),tags[key] == nil else { continue } guard let key = item.commonKey,[AVMetadataKey.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher].contains(key),tags[key] == nil else { continue }
if let value = try? await item.load(.stringValue) { tags[key] = value } if let value = try? await item.load(.stringValue) { tags[key] = value }
} }
result.set(tags);ready.signal() result.set(tags)
} }
defer { task.cancel() } defer { task.cancel();asset.cancelLoading() }
while ready.wait(timeout:.now()+0.05) == .timedOut { let deadline = ProcessInfo.processInfo.systemUptime+max(0.01,metadataTimeout)
while ready.wait(timeout:.now()+0.02) == .timedOut {
if cancelled() { throw CancellationError() } if cancelled() { throw CancellationError() }
if ProcessInfo.processInfo.systemUptime >= deadline { return nil }
} }
if cancelled() { throw CancellationError() } if cancelled() { throw CancellationError() }
return result.get() return result.get()
+26 -14
View File
@@ -719,6 +719,7 @@ struct ImportProgressPanel: View {
var background: (() -> Void)? = nil var background: (() -> Void)? = nil
var isMove = false var isMove = false
var activity: String? = nil var activity: String? = nil
var waveformStatus: BackgroundWaveformStatus? = nil
private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 } private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 }
private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan } private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan }
private var title: String { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" } private var title: String { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
@@ -755,7 +756,8 @@ struct ImportProgressPanel: View {
count("Skipped",value:progress.skipped,color:.orange) count("Skipped",value:progress.skipped,color:.orange)
count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan) count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan)
} }
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:260) if let waveformStatus { waveformStatus }
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:waveformStatus == nil ? 260 : 200)
Divider() Divider()
HStack { HStack {
Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary) Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
@@ -785,7 +787,8 @@ struct ImportLogView: View {
var title = "Import Log" var title = "Import Log"
@State private var follow = true @State private var follow = true
var body: some View { var body: some View {
let lines = progress.reportLines let allLines = progress.reportLines
let lines = Array(allLines.suffix(300))
VStack(alignment:.leading,spacing:8) { VStack(alignment:.leading,spacing:8) {
HStack { HStack {
Text(title).font(.headline) Text(title).font(.headline)
@@ -793,13 +796,14 @@ struct ImportLogView: View {
if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) } if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) }
Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) } Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) }
} }
if allLines.count > lines.count { Text("Showing the latest \(lines.count.formatted()) of \(allLines.count.formatted()) entries. Copy Log includes the full report.").font(.caption).foregroundStyle(.secondary) }
ScrollViewReader { reader in ScrollViewReader { reader in
ScrollView { ScrollView {
LazyVStack(alignment:.leading,spacing:7) { LazyVStack(alignment:.leading,spacing:7) {
ForEach(lines.indices,id:\.self) { index in ForEach(lines.indices,id:\.self) { index in
let line = lines[index] let line = lines[index]
HStack(alignment:.top,spacing:8) { HStack(alignment:.top,spacing:8) {
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : line.hasPrefix("[SKIPPED]") ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5) Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : (line.hasPrefix("[SKIPPED]") || line.hasPrefix("[WARNING]")) ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
}.id(index) }.id(index)
} }
@@ -807,7 +811,7 @@ struct ImportLogView: View {
}.padding(12) }.padding(12)
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8)) }.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06))) .overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
.onStudioChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } } .onStudioChange(of:allLines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
} }
} }
} }
@@ -989,10 +993,6 @@ struct LibraryView: View {
if let progress = model.automaticWaveformProgress { if let progress = model.automaticWaveformProgress {
BackgroundWaveformStatus(progress:progress,cancel:{model.automaticWaveforms.cancel()},dismiss:{model.automaticWaveformProgress = nil}).padding(.horizontal,18).padding(.vertical,8) BackgroundWaveformStatus(progress:progress,cancel:{model.automaticWaveforms.cancel()},dismiss:{model.automaticWaveformProgress = nil}).padding(.horizontal,18).padding(.vertical,8)
} }
if let progress = model.waveformJob {
WaveformProgressCard(progress:progress,running:model.generatingWaveforms,cancel:{model.cancelWaveformGeneration()},details:{waveformDetails = true},dismiss:{model.dismissWaveformProgress()})
.padding(.horizontal,18).padding(.vertical,10)
}
Divider() Divider()
footer.padding(.horizontal,18).padding(.vertical,8) footer.padding(.horizontal,18).padding(.vertical,8)
}.frame(minWidth:780) }.frame(minWidth:780)
@@ -1049,12 +1049,24 @@ struct LibraryView: View {
if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) } if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) }
} }
} }
.sheet(isPresented:$waveformDetails) { .sheet(isPresented:Binding(get:{model.waveformJob != nil},set:{if !$0 { model.dismissWaveformProgress() }})) {
VStack(alignment:.leading,spacing:14) { if let progress = model.waveformJob {
Text("Waveform Analysis").font(.title2.bold()) VStack(alignment:.leading,spacing:20) {
ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300) Label("Karaoke Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(.cyan)
HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) } Text(model.generatingWaveforms ? "Preparing saved waveforms in the background. Playback remains available." : progress.cancelled ? "Generation cancelled. Completed waveforms have been kept." : progress.issues > 0 ? "Some tracks could not be analysed. See the log below." : "Waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary)
}.padding(24).frame(width:560) WaveformProgressCard(progress:progress,running:model.generatingWaveforms,studio:"Karaoke Studio",cancel:{model.cancelWaveformGeneration()},details:{waveformDetails.toggle()},dismiss:{model.dismissWaveformProgress()})
if waveformDetails || !progress.messages.isEmpty {
Text("Analysis Log").font(.headline)
ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180)
}
HStack {
if model.generatingWaveforms { ProgressView().controlSize(.small).tint(.cyan);Text("Cancel keeps completed waveforms.").font(.caption).foregroundStyle(.secondary) }
Spacer()
if !model.generatingWaveforms { Button("Done") { model.dismissWaveformProgress() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) }
}
}.padding(26).frame(width:660).background(Color(nsColor:.windowBackgroundColor))
.interactiveDismissDisabled(model.generatingWaveforms)
}
} }
.sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) } .sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) }
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) } .sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
+39 -1
View File
@@ -135,6 +135,33 @@ enum WaveformBatch {
} }
} }
/// Blocking decoders and admission waits must not occupy Swift's cooperative executor.
/// Cancellation is captured explicitly because GCD blocks have no Swift task context.
enum WaveformWorker {
private final class Cancellation: @unchecked Sendable {
private let lock = NSLock()
private var value = false
var isCancelled: Bool { lock.lock();defer { lock.unlock() };return value }
func cancel() { lock.lock();value = true;lock.unlock() }
}
static func run<T: Sendable>(_ operation: @escaping @Sendable (@escaping @Sendable () -> Bool) throws -> T) async throws -> T {
let cancellation = Cancellation()
return try await withTaskCancellationHandler(operation: {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos:.utility).async {
do {
if cancellation.isCancelled { throw CancellationError() }
let result = try operation { cancellation.isCancelled }
if cancellation.isCancelled { throw CancellationError() }
continuation.resume(returning:result)
} catch { continuation.resume(throwing:error) }
}
}
},onCancel:{ cancellation.cancel() })
}
}
/// Admission control is read on workers; starting playback reduces future admissions. /// Admission control is read on workers; starting playback reduces future admissions.
final class WaveformWorkload: @unchecked Sendable { final class WaveformWorkload: @unchecked Sendable {
static let shared = WaveformWorkload() static let shared = WaveformWorkload()
@@ -172,9 +199,20 @@ extension WaveformBatch {
let tracks = groups[next];next += 1;active += 1 let tracks = groups[next];next += 1;active += 1
state.current = tracks.first?.title ?? "Preparing waveforms…" state.current = tracks.first?.title ?? "Preparing waveforms…"
group.addTask(priority:.utility) { group.addTask(priority:.utility) {
run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled}) do {
return try await WaveformWorker.run { cancelled in
run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:cancelled)
}
} catch {
var result = WaveformJobProgress(total:tracks.count)
result.cancelled = true;result.finished = true
return result
}
} }
} }
// Publish admission before awaiting a decoder: otherwise a long first track
// leaves the interface at “Preparing waveforms” with no visible activity.
if active > 0 { progress(state) }
guard active > 0,let result = await group.next() else { break } guard active > 0,let result = await group.next() else { break }
active -= 1 active -= 1
state.processed += result.processed;state.generated += result.generated;state.cached += result.cached state.processed += result.processed;state.generated += result.generated;state.cached += result.cached
+18 -12
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>?
@@ -433,24 +437,26 @@ import CoreAudio
let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false
let directory = waveformCacheDirectory let directory = waveformCacheDirectory
waveformTask = Task.detached(priority:.utility) { [weak self] in waveformTask = Task.detached(priority:.utility) { [weak self] in
let result: Result<AudioWaveform.Analysis,Error> let result: Result<(AudioWaveform.Analysis,Bool,Bool),Error>
var cached = false,saved = false
do { do {
if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { result = .success(try await WaveformWorker.run { cancelled in
result = .success(analysis);cached = true;saved = true if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) {
} else { return (analysis,true,true)
let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled }) }
if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil } let analysis = try AudioWaveform.analyse(url:url,cancelled:cancelled)
result = .success(analysis) if cancelled() { throw CancellationError() }
} let saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil
return (analysis,false,saved)
})
} catch { result = .failure(error) } } catch { result = .failure(error) }
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
let loadedFromCache = cached,analysisSaved = saved
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self,self.waveformGeneration == token else { return } guard let self,self.waveformGeneration == token else { return }
self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved self.waveformLoading = false
switch result { switch result {
case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands case .success(let (analysis,cached,saved)):
self.waveformFromCache = cached;self.waveformSaved = saved
self.waveform = analysis.levels;self.waveformBands = analysis.bands
case .failure:self.waveformUnavailable = true case .failure:self.waveformUnavailable = true
} }
} }
+205 -9
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")
} }
} }
@@ -2358,10 +2372,11 @@ extension KJamsCloneTests {
} }
model.dj.automix.enqueue([song,song]);try await selectTab(1) model.dj.automix.enqueue([song,song]);try await selectTab(1)
let libraryScroll = try XCTUnwrap(descendants(host).compactMap{$0 as? NSScrollView}.filter{$0.bounds.width > 600 && !$0.isHiddenOrHasHiddenAncestor}.max(by:{$0.bounds.height < $1.bounds.height})) let libraryScroll = try XCTUnwrap(descendants(host).compactMap{$0 as? NSScrollView}.filter{$0.bounds.width > 600 && !$0.isHiddenOrHasHiddenAncestor}.max(by:{$0.bounds.height < $1.bounds.height}))
XCTAssertGreaterThan(libraryScroll.bounds.height,380,"DJ library grows into the remaining workspace height") XCTAssertGreaterThan(libraryScroll.bounds.height,250,"The resizable DJ library retains a useful track viewport")
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")) } let libraryFrame = libraryScroll.convert(libraryScroll.bounds,to:host)
if host.isFlipped { XCTAssertGreaterThan(libraryFrame.maxY,host.bounds.maxY-50,"The library fills the bottom of the workspace") }
else { XCTAssertLessThan(libraryFrame.minY,host.bounds.minY+50,"The library fills the bottom of the workspace") }
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")) }
} }
} }
@@ -2766,11 +2781,40 @@ extension KJamsCloneTests {
} }
var duplicate = songs[0];duplicate.id = 10;songs.append(duplicate) var duplicate = songs[0];duplicate.id = 10;songs.append(duplicate)
let cache = root.appendingPathComponent("Cache") let cache = root.appendingPathComponent("Cache")
let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2}) var updates = [WaveformJobProgress]()
let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2},progress:{updates.append($0)})
XCTAssertEqual(updates.first?.processed,0,"Show the admitted track before waiting for its decoder")
XCTAssertEqual(updates.first?.current,"Track")
XCTAssertEqual(updates.first?.finished,false)
XCTAssertEqual(result.generated,4);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.processed,5);XCTAssertEqual(result.issues,0) XCTAssertEqual(result.generated,4);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.processed,5);XCTAssertEqual(result.issues,0)
let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1}) let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1})
XCTAssertEqual(reused.cached,5);XCTAssertEqual(reused.generated,0) XCTAssertEqual(reused.cached,5);XCTAssertEqual(reused.generated,0)
} }
@MainActor func testWaitingWaveformWorkerCancelsWithoutBlockingTaskExecutor() async throws {
let policy = WaveformWorkload.shared,id = UUID()
policy.setPlaying(true,id:id)
try policy.acquire(cancelled:{false})
var held = true
defer { if held { policy.release() };policy.setPlaying(false,id:id) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let waiting = Task {
try await WaveformWorker.run { cancelled in
try AudioWaveform.analyse(url:fixture,cancelled:cancelled)
}
}
// This continuation must run even while decoding is waiting for admission.
try await Task.sleep(nanoseconds:50_000_000)
waiting.cancel()
do { _ = try await waiting.value;XCTFail("Cancelled analysis should not produce a waveform") }
catch { XCTAssertTrue(error is CancellationError) }
policy.release();held = false
let analysis = try await WaveformWorker.run { cancelled in
try AudioWaveform.analyse(url:fixture,cancelled:cancelled)
}
XCTAssertEqual(analysis.levels.count,4096)
XCTAssertGreaterThan(analysis.levels.max() ?? 0,0)
}
@MainActor func testWaveformAdmissionDropsToOneDuringPlayback() throws { @MainActor func testWaveformAdmissionDropsToOneDuringPlayback() throws {
let policy = WaveformWorkload.shared,id = UUID() let policy = WaveformWorkload.shared,id = UUID()
policy.setPlaying(true,id:id) policy.setPlaying(true,id:id)
@@ -3189,3 +3233,155 @@ extension KJamsCloneTests {
} }
extension KJamsCloneTests {
func testDJCollectionsCombineSearchRatingsAndPlaylistOrder() {
let tracks = [
Song(id:1,title:"Track 10",audio:"/music/10.mp3",cdg:nil,rating:5,plays:0,artist:"Northbound",album:"Night"),
Song(id:2,title:"Track 2",audio:"/music/2.mp3",cdg:nil,rating:0,plays:0,artist:"Mira Vale",album:"Night"),
Song(id:3,title:"Track 1",audio:"/music/1.mp3",cdg:nil,rating:4,plays:0,artist:"")
]
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[]).map(\.id),[3,2,1],"Titles use natural numeric ordering")
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.rated,favourites:[],query:"Northbound").map(\.id),[1])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.favourites,favourites:[2],query:"Night").map(\.id),[2])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.missingArtist,favourites:[]).map(\.id),[3])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.unrated,favourites:[]).map(\.id),[2])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[],playlist:[2,1,99,3],sort:.source).map(\.id),[2,1,3],"Playlist order is retained; missing rows are ignored")
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[],sort:.rating,ascending:false).map(\.id),[1,3,2])
XCTAssertEqual(DJLibraryBrowser.rows(Array(tracks.reversed()),collection:.all,favourites:[],sort:.source).map(\.id),[1,2,3],"Import order follows stable database IDs, not the prior display sort")
}
@MainActor func testRedesignedDJWorkspaceAtCompactAndWideSizes() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-DJ-Design-"+UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let system = DJSystem(storageURL:root.appendingPathComponent("Library"))
defer { system.a.player.stop();system.b.player.stop();system.preview.stop() }
let library = try XCTUnwrap(system.library)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let titles = ["Night Transit","Glass City","After Hours","Satellite","Deep Blue","Into the Light","Parallel Lines","Last Train"]
for (index,title) in titles.enumerated() {
let url = root.appendingPathComponent("Track-\(index).mp3");try FileManager.default.copyItem(at:fixture,to:url)
let id = try library.add(title:title,audio:url.path,cdg:nil,artist:index.isMultiple(of:2) ? "Northbound" : "Mira Vale",album:"Night Sessions",duration:180+Double(index*12))
try library.rate(id,index.isMultiple(of:3) ? 5 : 3)
}
try system.refresh();system.createPlaylist("Opening Set");system.createPlaylist("Late Night")
if let list = system.playlists.first { for song in system.songs.prefix(3) { system.addToPlaylist(list.id,song:song.id) } }
system.toggleFavourite(try XCTUnwrap(system.songs.first?.id))
system.load(system.songs[0],into:"A");system.load(system.songs[1],into:"B")
system.a.bpm = 120;system.a.bpmConfirmed = true;system.b.bpm = 124;system.b.bpmConfirmed = true
for _ in 0..<100 where system.a.player.waveformLoading || system.b.player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) }
for size in [CGSize(width:1320,height:900),CGSize(width:1060,height:760)] {
let view = NSHostingView(rootView:DJConsole(system:system).frame(width:size.width,height:size.height).preferredColorScheme(.dark))
view.frame = NSRect(origin:.zero,size:size)
let window = NSWindow(contentRect:view.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = view;window.appearance = NSAppearance(named:.darkAqua);window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
try await Task.sleep(nanoseconds:250_000_000);view.layoutSubtreeIfNeeded()
if size.width == 1320 {
system.a.player.filter = 0.63;system.b.player.filter = -0.31
try await Task.sleep(nanoseconds:60_000_000);view.layoutSubtreeIfNeeded()
func knobs(_ parent: NSView) -> [DJRotaryControl] { (parent as? DJRotaryControl).map{[$0]} ?? parent.subviews.flatMap{knobs($0)} }
let filters = knobs(view).filter{$0.label == "FILTER"}
XCTAssertEqual(filters.count,2)
XCTAssertTrue(filters.contains{abs($0.value-0.63) < 0.001});XCTAssertTrue(filters.contains{abs($0.value+0.31) < 0.001},"External audio-model changes update both mixer strips")
for filter in filters { filter.resetToDefault() }
XCTAssertEqual(system.a.player.filter,0);XCTAssertEqual(system.b.player.filter,0)
}
let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds));view.cacheDisplay(in:view.bounds,to:rep)
let png = try XCTUnwrap(rep.representation(using:.png,properties:[:]))
let path = root.appendingPathComponent("DJ-Studio-\(Int(size.width)).png");try png.write(to:path)
print("DJ_DESIGN_CAPTURE \(path.path)")
XCTContext.runActivity(named:"DJ Studio \(Int(size.width)) layout") { activity in
let capture = XCTAttachment(data:png,uniformTypeIdentifier:"public.png");capture.lifetime = .keepAlways;activity.add(capture)
}
XCTAssertFalse(system.a.player.playing);XCTAssertFalse(system.b.player.playing)
window.orderOut(nil);window.contentView = nil
}
}
}
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)
}
}
extension KJamsCloneTests {
func testDJImportContinuesAfterUnreadableFilesAndStalledTags() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
for (index,name) in ["A Slow Tags","B Good","C Good","D Good"].enumerated() {
let samples = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));samples.frameLength = 1024
for frame in 0..<1024 { samples.floatChannelData![0][frame] = Float(sin(Double(frame)*Double(index+1)*0.1))*0.2 }
let file = try AVAudioFile(forWriting:input.appendingPathComponent(name+".wav"),settings:format.settings);try file.write(from:samples)
}
try Data().write(to:input.appendingPathComponent("AA Empty.wav"))
let library = try Library(url:root.appendingPathComponent("Library.sqlite"))
let importer = Importer(media:root.appendingPathComponent("Media"),audioExtensions:["wav"],includeGraphics:false,continueOnUnreadableAudio:true,metadataTimeout:0.08,metadataLoader:{ url in
if url.lastPathComponent.hasPrefix("A Slow") { try await Task.sleep(nanoseconds:10_000_000_000) }
return [.commonKeyTitle:"Tagged "+url.deletingPathExtension().lastPathComponent]
})
var report = ImportProgress();let began = ProcessInfo.processInfo.systemUptime
let ids = try importer.run([input],library:library,copyMedia:false,progress:{report = $0})
XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,3,"A stalled tag read must not hold up later files")
XCTAssertEqual(ids.count,4);XCTAssertEqual(try library.songs().count,4)
XCTAssertEqual(report.total,5);XCTAssertEqual(report.completed,5);XCTAssertEqual(report.skipped,1);XCTAssertTrue(report.finished)
XCTAssertTrue(report.logLines.contains{$0.contains("tag read timed out")})
XCTAssertTrue(report.logLines.contains{$0.contains("AA Empty.wav") && $0.hasPrefix("[SKIPPED]")})
XCTAssertTrue(try library.songs().contains{$0.title == "A Slow Tags"});XCTAssertTrue(try library.songs().contains{$0.title == "Tagged D Good"})
}
func testImportCancellationInterruptsStalledMetadataWait() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let library = try Library(url:root.appendingPathComponent("Library.sqlite")),began = ProcessInfo.processInfo.systemUptime
let importer = Importer(media:root.appendingPathComponent("Media"),metadataTimeout:20,metadataLoader:{ _ in try await Task.sleep(nanoseconds:10_000_000_000);return [:] })
XCTAssertThrowsError(try importer.run([fixture],library:library,copyMedia:false,cancelled:{ProcessInfo.processInfo.systemUptime-began > 0.12})) { XCTAssertTrue($0 is CancellationError) }
XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,1)
XCTAssertTrue(try library.songs().isEmpty)
}
@MainActor func testLargeDJImportKeepsUIResponsiveAndCompletesEveryInput() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
for index in 0..<128 {
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));buffer.frameLength = 1024
for frame in 0..<1024 { buffer.floatChannelData![0][frame] = Float(sin(Double(frame)*0.1+Double(index)))*0.2 }
let file = try AVAudioFile(forWriting:input.appendingPathComponent(String(format:"Unique %04d.wav",index)),settings:format.settings);try file.write(from:buffer)
}
for index in 0..<512 { try FileManager.default.copyItem(at:input.appendingPathComponent("Unique 0000.wav"),to:input.appendingPathComponent(String(format:"Duplicate %04d.wav",index))) }
let system = DJSystem(storageURL:root.appendingPathComponent("DJ"))
system.importFiles([input]);XCTAssertTrue(system.importing);XCTAssertTrue(system.importLogVisible)
var heartbeat = false;Task { @MainActor in heartbeat = true }
system.crossfader = 0.75
for _ in 0..<1000 where system.importing { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(heartbeat);XCTAssertFalse(system.importing);XCTAssertEqual(system.crossfader,0.75)
let report = try XCTUnwrap(system.lastImportReport)
XCTAssertEqual(report.completed,640);XCTAssertEqual(report.total,640);XCTAssertEqual(report.skipped,512)
XCTAssertNil(report.failure);XCTAssertEqual(system.songs.count,128,"Every distinct track is saved, while repeated audio remains deduplicated")
system.automaticWaveforms.cancel()
for _ in 0..<200 where system.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:5_000_000) }
}
}
+12
View File
@@ -132,3 +132,15 @@ Both library toolbars share coloured, rounded action buttons and matching search
Loop activation and cancellation prepare an idle transport node while the audible node keeps running. Both feed the same effects chain. A shared host-clock start (up to 50 ms lookahead) and per-sample 12 ms complementary envelopes overlap the streams before retiring the outgoing node. The first partial loop pass uses the same processed seam as subsequent passes. Regression checks measure silent runs and adjacent-sample jumps across repeated start/stop operations, including two playing decks and changed tempos, as well as actual loop content and natural end-of-track completion. Loop activation and cancellation prepare an idle transport node while the audible node keeps running. Both feed the same effects chain. A shared host-clock start (up to 50 ms lookahead) and per-sample 12 ms complementary envelopes overlap the streams before retiring the outgoing node. The first partial loop pass uses the same processed seam as subsequent passes. Regression checks measure silent runs and adjacent-sample jumps across repeated start/stop operations, including two playing decks and changed tempos, as well as actual loop content and natural end-of-track completion.
DJ Studio’s performance workspace uses a teal/violet palette, separate track headers, full-width stacked scrolling waveforms, two deck performance panels and a central two-channel mixer. Each performance panel groups pitch/sync, platter, transport, hot cues and loop controls. Cue and loop editors are in the flag menu. The mixer groups both channels’ EQ/filter/level controls above the crossfader; master level is in the studio header. The performance/library divider and collection/table divider resize independently.
The library sidebar provides All Music, Favourites, Top Rated (four or five stars), Unrated and Needs Metadata (missing artist), plus existing playlists. Sorting supports title, artist, album, duration, rating and original playlist/import order; use the arrow to reverse it. Playlist selection retains playlist order by default. Search, multiple selection, select-all, native column resizing, ratings, context actions, waveform jobs and paged scrolling remain available. Selecting a track reveals Info and Load A/B controls below the table. The collection sidebar scrolls and can be hidden from the header. All views use the existing independent DJ database and playlists; no external resources or third-party artwork are included.
EQ and filter knobs use native mouse-event click counting, so double-clicking either the knob or its label restores its defined default (0 dB for EQ and centred filter). Dragging remains vertical; arrow keys adjust the focused control and Space resets it. Resize handle positions and collection-sidebar visibility are remembered between launches.
The eraser menu beside IN/OUT clears individual hot cues, all hot cues, the main cue, loop markers, or every cue/loop marker on that deck. Hot cues also support Option-click and right-click to clear. Clearing the main cue resets it to track start without seeking; clearing markers retains the loaded track and playback. Hover descriptions cover the performance controls, mixer, library actions, queue controls and cue/loop editors.
Large DJ imports show scanning, existing-library checks, file reading, tag reading and saving stages in the import modal. Tag reads have a two-second deadline: a stalled read is cancelled, the track is imported using its filename, and the warning is retained in the log. DJ imports skip unreadable audio and continue with later tracks; destination/storage and database failures still stop the operation. Each track’s database rows and import-history records commit together, preserving completed work on cancellation.
Import and automatic-waveform feedback is shown only in the import modal, reopened using the document icon in the DJ Studio header. The workspace no longer shows import banners or background waveform feedback. Large logs display only their latest 300 entries for responsive scrolling; Copy Log retains the complete report. Waveforms remain a separate background job after track import.