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) {
HStack { Label("DJ Queue",systemImage:"text.line.first.and.arrowtriangle.forward").font(.headline);Spacer();Text("\(automix.entries.count)").foregroundStyle(.secondary) }
HStack {
Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor)
Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil)
Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor).help("Start or stop automatic transitions through the queued tracks")
Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil).help("Transition immediately to the next queued track using the selected fade duration")
}.controlSize(.small)
HStack {
Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1);Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34)
Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1).help("Duration in seconds for each automatic crossfade");Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34)
}.font(.caption)
Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption)
Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption).help("Match confirmed track tempos before the automatic transition")
if let progress = automix.progress { ProgressView(value:progress) }
List {
ForEach(automix.entries) { entry in
HStack(spacing:5) {
VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
Spacer(minLength:0)
Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier")
Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later")
Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue")
Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier").help("Move this queued track one place earlier")
Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later").help("Move this queued track one place later")
Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue").help("Remove this track from the queue without deleting it from the library")
}.font(.caption).buttonStyle(.borderless)
}
if automix.entries.isEmpty { Text("Add songs with + or drag them here from the DJ library.").font(.caption).foregroundStyle(.secondary) }
@@ -183,7 +183,7 @@ struct DJQueuePane: View {
}
return true
}
HStack { Button("Shuffle") { automix.shuffle() };Button("Clear") { automix.clear() };Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty)
HStack { Button("Shuffle") { automix.shuffle() }.help("Randomise the order of the remaining queued tracks");Button("Clear") { automix.clear() }.help("Remove all queued entries; library tracks are kept");Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty)
Text(automix.message).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
}.padding(12).background(Color.white.opacity(0.035),in:RoundedRectangle(cornerRadius:6))
}
+3 -3
View File
@@ -137,11 +137,11 @@ struct DJLoopEditor: View {
var body: some View {
VStack(alignment:.leading,spacing:18) {
Text("Deck \(letter) · Loop Points").font(.title2.bold())
HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time } }
HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time } }
HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time }.help("Use the current playback position as the loop start") }
HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time }.help("Use the current playback position as the loop end") }
Text(valid ? String(format:"Loop length: %.3f seconds",end-start) : "End must be at least 0.1 seconds after start, within the track.").font(.caption).foregroundStyle(valid ? Color.secondary : Color.orange)
Text("On either waveform, drag the green edges to resize or the loop region to move. Shift-drag defines a new loop. Playback continues while editing.").font(.callout).foregroundStyle(.secondary)
HStack { Button("Cancel") { dismiss() };Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction) }
HStack { Button("Cancel") { dismiss() }.help("Close without changing the loop");Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction).help("Apply these start/end points and enable looping") }
}.padding(24).frame(width:540)
}
}
+281 -152
View File
@@ -373,6 +373,7 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var importLogVisible = false
@Published private(set) var importing = false
private var importTask: Task<Void,Never>?
private var importGeneration = UUID()
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
init(storageURL: URL? = nil) {
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) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
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}
importTask = Task.detached(priority:.utility) { [weak self] in
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)
}
}
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
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 { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest,loadedIDs = importedIDs
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.perform { try self.refresh() }
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() {
guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return }
let next = queuedFileLoads.removeFirst()
@@ -981,7 +983,7 @@ private struct DJQueueButton: View {
let open: () -> Void
var body: some View {
Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") }
.tint(automix.enabled ? .green : .accentColor)
.tint(automix.enabled ? .green : .accentColor).help("Open the DJ queue, reorder tracks and control Automix")
}
}
@@ -1004,6 +1006,15 @@ enum DJCollection: String,CaseIterable,Identifiable {
case all = "All Music",favourites = "Favourites",rated = "Top Rated",unrated = "Unrated",missingArtist = "Needs Metadata"
var id: String { rawValue }
var icon: String { switch self { case .all:return "music.note.list";case .favourites:return "star.fill";case .rated:return "rosette";case .unrated:return "star";case .missingArtist:return "tag" } }
func includes(_ song: Song,favourites: Set<Int64>) -> Bool {
switch self {
case .all:return true
case .favourites:return favourites.contains(song.id)
case .rated:return song.rating >= 4
case .unrated:return song.rating == 0
case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty
}
}
}
enum DJLibrarySort: String,CaseIterable,Identifiable {
case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order"
@@ -1016,15 +1027,12 @@ enum DJLibraryBrowser {
else { source = songs }
let filtered = source.filter { song in
guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false }
switch collection {
case .all:return true
case .favourites:return favourites.contains(song.id)
case .rated:return song.rating >= 4
case .unrated:return song.rating == 0
case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty
}
return collection.includes(song,favourites:favourites)
}
guard sort != .source else {
let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered
return ascending ? ordered : Array(ordered.reversed())
}
guard sort != .source else { return ascending ? filtered : Array(filtered.reversed()) }
return filtered.sorted { lhs,rhs in
let comparison: ComparisonResult
switch sort {
@@ -1055,6 +1063,10 @@ struct DJConsole: View {
@State private var ascending = true
@State private var collectionCounts = [DJCollection:Int]()
@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 query = ""
@State private var playlist: Int64?
@@ -1072,7 +1084,7 @@ struct DJConsole: View {
libraryRevision = UUID()
filteredTracks = DJLibraryBrowser.rows(system.songs,collection:collection,favourites:system.favourites,playlist:playlist == nil ? nil : playlistIDs,query:query,sort:librarySort,ascending:ascending)
collectionCounts = Dictionary(uniqueKeysWithValues:DJCollection.allCases.map { scope in
(scope,DJLibraryBrowser.rows(system.songs,collection:scope,favourites:system.favourites,sort:.source).count)
(scope,system.songs.lazy.filter{!$0.audio.hasPrefix("showscreen:") && scope.includes($0,favourites:system.favourites)}.count)
})
selection.formIntersection(Set(filteredTracks.map(\.id)))
page = min(page,max(0,(filteredTracks.count+49)/50-1))
@@ -1097,22 +1109,21 @@ struct DJConsole: View {
var body: some View {
VStack(spacing:10) {
header
VSplitView {
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(minHeight:408,idealHeight:438,maxHeight:560)
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)
GeometryReader { geometry in
VStack(spacing:0) {
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 {
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)
}.padding(14).frame(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top)
.background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark)
.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?") }
.sheet(isPresented:$system.importLogVisible) {
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)
}
}
@@ -1174,29 +1185,33 @@ struct DJConsole: View {
.onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() }
.onStudioChange(of:sidebarWidth) { _ in libraryRevision = UUID() }
.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 {
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)
VStack(alignment:.leading,spacing:3) { Text("DJ STUDIO").font(.system(size:15,weight:.bold,design:.rounded)).tracking(2);Text("PERFORMANCE WORKSPACE").font(.system(size:8,weight:.medium)).tracking(1.8).foregroundStyle(.secondary) }
Spacer()
HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28).help("Adjust the overall DJ output level for both decks") }
Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a))
Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray))
Button { 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})
if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green) }
if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green).help("Stop independent headphone preview") }
}.controlSize(.small).padding(.horizontal,2).padding(.bottom,2)
}
private var libraryTitle: String { playlist.flatMap { id in system.playlists.first{$0.id == id}?.name } ?? collection.rawValue }
private func chooseCollection(_ value: DJCollection) { playlist = nil;collection = value;librarySort = .title;ascending = true;refreshPlaylist();page = 0 }
private var collectionSidebar: some View {
ScrollView {
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)
ForEach(DJCollection.allCases) { scope in
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)
ScrollView {
VStack(spacing:2) {
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 }
@@ -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) }
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 {
Button(action:action) {
HStack(spacing:9) { Image(systemName:icon).frame(width:16).foregroundStyle(active ? DJStyle.a : .secondary);Text(name).lineLimit(1);Spacer(minLength:2);if let count { Text(count.formatted()).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) } .font(.system(size:11,weight:active ? .semibold : .regular)).padding(.horizontal,12).padding(.vertical,9).background(active ? DJStyle.a.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:7))
}.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "")
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" : "").help("Show \(name)")
}
private var browser: some View {
HSplitView {
if sidebarVisible { collectionSidebar }
GeometryReader { geometry in
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) {
HStack(spacing:12) {
VStack(alignment:.leading,spacing:3) { Text(libraryTitle).font(.system(size:17,weight:.semibold));Text("\(songs.count.formatted()) tracks").font(.caption).foregroundStyle(.secondary) }
Spacer(minLength:8)
StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340)
Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true))
StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340).help("Filter this collection or playlist by title, artist, album or other track metadata")
Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true)).help("Import audio files or folders, including subfolders; already imported tracks are skipped")
}.padding(14)
HStack(spacing:10) {
Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155)
Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155).help("Sort the visible tracks; playlist/import order preserves the original sequence")
Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.buttonStyle(.plain).help("Reverse sort order")
LibrarySelectionBadge(count:selection.count)
Spacer(minLength:4)
Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty)
Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty).help("Add every selected track to the DJ queue")
HStack(spacing:8) {
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0)
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0).help("Previous library page")
Text("\(min(page,pages-1)+1) / \(pages)").font(.system(size:10).monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1).help("Next library page")
}.buttonStyle(.plain)
}.controlSize(.small).padding(.horizontal,14).padding(.bottom,10)
Divider().overlay(Color.white.opacity(0.05))
ScrollViewReader { reader in
Table(visible,selection:$selection) {
TableColumn("Favourite") { 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)")
}.width(55)
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)").help(system.favourites.contains(song.id) ? "Remove this track from Favourites" : "Add this track to Favourites")
}.width(38)
TableColumn("Title") { song in
DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()})
.frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight)
}.width(min:180,ideal:400,max:1600)
TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:100,ideal:200,max:800)
TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:90,ideal:180,max:800)
}.width(min:150,ideal:260,max:1200)
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: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("Rating") { song in
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)
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))
.contextMenu(forSelectionType:Int64.self) { ids in
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)
VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:11,weight:.semibold)).lineLimit(1);Text(song.artist.isEmpty ? "Artist not set" : song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray))
Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a))
Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b))
Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).help("Edit the selected track’s title, artist, album and metadata")
Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)).help("Load the selected track into deck A; a playing deck is protected")
Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b)).help("Load the selected track into deck B; a playing deck is protected")
}.padding(10).background(DJStyle.background)
}
}.frame(minWidth:530,maxWidth:.infinity,maxHeight:.infinity).background(DJStyle.panel)
}
}.clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(.white.opacity(0.07)))
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
@@ -1372,12 +1393,11 @@ private struct DJDeckSurfaces {
@ObservedObject var system: DJSystem
private var color: Color { DJStyle.color(deck.letter) }
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")
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")
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)
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))
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) }.help("Decrease tempo by 0.1 percentage points");Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) }.help("Increase tempo by 0.1 percentage points") }.buttonStyle(.plain).font(.system(size:15,weight:.medium))
}.frame(width:62).disabled(!player.canJog)
}
var tonePanel: some View {
@@ -1387,13 +1407,13 @@ private struct DJDeckSurfaces {
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)
}
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)})
HStack(spacing:3) {
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) }
}.accessibilityLabel("Deck output meter")
DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:94)
}.accessibilityLabel("Deck output meter").help("Live output peak level for this deck")
DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:70)
}
Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary)
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
@Binding var value: Double
let range: ClosedRange<Double>
let reset: Double
let color: Color
var format: (Double) -> String = {String(format:"%+.0f dB",$0)}
@State private var initial: Double?
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) }
var body: some View {
VStack(spacing:1) {
ZStack {
Circle().fill(Color.black.opacity(0.35)).overlay { Circle().stroke(.white.opacity(0.12),lineWidth:2) }
Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90))
Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270))
}.frame(width:29,height:29).contentShape(Circle())
.simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil})
Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary)
}.frame(minWidth:36).contentShape(Rectangle())
.highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil })
.accessibilityAction(named:Text("Reset to default")) { value = reset;initial = nil }
.help("\(label): \(format(value)). Drag vertically; double-click to reset.")
.accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value))
.accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) }
func makeCoordinator() -> Coordinator { Coordinator(value:$value) }
func makeNSView(context: Context) -> DJRotaryControl {
let view = DJRotaryControl();view.changed = { [weak coordinator = context.coordinator] value in coordinator?.value.wrappedValue = value };return view
}
func updateNSView(_ view: DJRotaryControl,context: Context) {
context.coordinator.value = $value
view.label = label;view.value = value;view.range = range;view.defaultValue = reset;view.colour = NSColor(color);view.isEnabled = context.environment.isEnabled
view.setAccessibilityElement(true);view.setAccessibilityRole(.slider);view.setAccessibilityLabel(label);view.setAccessibilityValue(format(value))
view.setAccessibilityCustomActions([NSAccessibilityCustomAction(name:"Reset to default",handler:{[weak view] in view?.resetToDefault();return true})])
view.toolTip = "\(label): \(format(value)). Drag vertically; double-click to reset."
view.needsDisplay = true
}
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJRotaryControl,context: Context) -> CGSize? { CGSize(width:36,height:40) }
final class Coordinator {
var value: Binding<Double>
init(value: Binding<Double>) { self.value = value }
}
}
/// Native click counting keeps a double-click reset independent of drag recognition.
final class DJRotaryControl: NSControl {
var label = "",value: Double = 0,defaultValue: Double = 0
var range: ClosedRange<Double> = -12...12
var colour = NSColor.systemTeal
var changed: ((Double) -> Void)?
private var dragStart: (point: NSPoint,value: Double)?
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
func resetToDefault() { guard isEnabled else { return };dragStart = nil;set(defaultValue) }
private func set(_ next: Double) { value = min(range.upperBound,max(range.lowerBound,next));needsDisplay = true;changed?(value) }
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
window?.makeFirstResponder(self)
if event.clickCount >= 2 { resetToDefault();return }
dragStart = (convert(event.locationInWindow,from:nil),value)
}
override func mouseDragged(with event: NSEvent) {
guard isEnabled,let start = dragStart else { return }
let point = convert(event.locationInWindow,from:nil)
set(start.value-Double(point.y-start.point.y)*(range.upperBound-range.lowerBound)/120)
}
override func mouseUp(with event: NSEvent) { dragStart = nil }
override func keyDown(with event: NSEvent) {
if event.keyCode == 126 || event.keyCode == 124 { set(value+(range.upperBound-range.lowerBound)/100) }
else if event.keyCode == 125 || event.keyCode == 123 { set(value-(range.upperBound-range.lowerBound)/100) }
else if event.keyCode == 49 { resetToDefault() }
else { super.keyDown(with:event) }
}
override func accessibilityPerformIncrement() -> Bool { guard isEnabled else { return false };set(value+(range.upperBound-range.lowerBound)/100);return true }
override func accessibilityPerformDecrement() -> Bool { guard isEnabled else { return false };set(value-(range.upperBound-range.lowerBound)/100);return true }
override func draw(_ dirtyRect: NSRect) {
let centre = NSPoint(x:bounds.midX,y:14.5)
let circle = NSBezierPath(ovalIn:NSRect(x:centre.x-14.5,y:0,width:29,height:29))
NSColor.black.withAlphaComponent(0.35).setFill();circle.fill()
NSColor.white.withAlphaComponent(0.12).setStroke();circle.lineWidth = 2;circle.stroke()
let arc = NSBezierPath();arc.appendArc(withCenter:centre,radius:14,startAngle:135,endAngle:405,clockwise:false)
colour.withAlphaComponent(isEnabled ? 0.4 : 0.15).setStroke();arc.lineWidth = 2;arc.stroke()
let fraction = min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound)))
let angle = (-135+fraction*270) * .pi/180
let marker = NSBezierPath();marker.move(to:NSPoint(x:centre.x+sin(angle)*5,y:centre.y-cos(angle)*5));marker.line(to:NSPoint(x:centre.x+sin(angle)*11,y:centre.y-cos(angle)*11));marker.lineWidth = 2;marker.lineCapStyle = .round
colour.withAlphaComponent(isEnabled ? 1 : 0.3).setStroke();marker.stroke()
let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center
(label as NSString).draw(in:NSRect(x:0,y:30,width:bounds.width,height:10),withAttributes:[.font:NSFont.systemFont(ofSize:8,weight:.medium),.foregroundColor:NSColor.secondaryLabelColor,.paragraphStyle:paragraph])
}
}
private struct DJPlatter: NSViewRepresentable {
@@ -1598,11 +1664,11 @@ struct DJHardwareFader: NSViewRepresentable {
slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical
slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value
slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false
slider.setAccessibilityLabel(label)
slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust."
slider.setContentHuggingPriority(.defaultLow,for:.horizontal)
return slider
}
func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label) }
func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust." }
func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSSlider,context: Context) -> CGSize? { CGSize(width:proposal.width ?? (vertical ? 52 : 240),height:proposal.height ?? (vertical ? 94 : 36)) }
final class Coordinator: NSObject {
var value: Binding<Double>
@@ -1639,33 +1705,59 @@ struct DJContinuousConsole: View {
let loadA: () -> Void
let loadB: () -> Void
var body: some View {
VStack(spacing:0) {
HStack(spacing:0) {
DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }
Divider()
DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }
}.frame(height:92).background(Color.black.opacity(0.4))
Divider()
HStack(spacing:0) {
DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:344)
Divider()
VStack(spacing:1) {
DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) }
DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }
}.frame(maxWidth:.infinity).background(.black)
Divider()
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))
Divider()
HStack(spacing:12) {
DJConsoleTransport(deck:system.a,player:system.a.player,system:system)
VStack(spacing:0) {
DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36).disabled(system.routing.mode == "External")
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))
}.frame(width:195)
DJConsoleTransport(deck:system.b,player:system.b.player,system:system)
}.padding(.horizontal,12).padding(.vertical,10).background(Color(white:0.14))
}.overlay(Rectangle().stroke(.white.opacity(0.09)))
VStack(spacing:8) {
HStack(spacing:10) {
DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10))
DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10))
}.frame(height:82)
VStack(spacing:2) {
DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) }
DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }
}.frame(minHeight:64,maxHeight:.infinity).background(.black).clipShape(RoundedRectangle(cornerRadius:8)).clipped()
HStack(alignment:.top,spacing:10) {
performance(system.a)
mixer.frame(width:292)
performance(system.b)
}.frame(height:252)
}.accessibilityIdentifier("dj.performance.workspace")
}
private func performance(_ deck: DJChannel) -> some View {
VStack(spacing:6) {
HStack { Text("DECK \(deck.letter)").foregroundStyle(DJStyle.color(deck.letter));Spacer();Text("PERFORMANCE").foregroundStyle(.secondary) }.font(.system(size:9,weight:.bold)).tracking(1.2)
DJDeckDropZone(system:system,letter:deck.letter) { DJConsoleWing(deck:deck,player:deck.player,system:system) }
Divider().overlay(.white.opacity(0.04))
DJConsoleTransport(deck:deck,player:deck.player,system:system)
}.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top)
.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
.overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(DJStyle.color(deck.letter)).frame(height:2).padding(.horizontal,14) }
}
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 {
@@ -1683,16 +1775,16 @@ private struct DJTrackHeader: View {
Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1)
HStack(spacing:6) {
Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1)
TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM")
TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM").help("Set the track’s original BPM to enable beat sync and beat loops")
Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary)
Button("Tap") { deck.tap() }.controlSize(.mini)
Button("Tap") { deck.tap() }.controlSize(.mini).help("Tap along with the beat to set this track’s BPM")
}.font(.system(size:10)).disabled(!player.canJog)
}
Spacer(minLength:4)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit())
Button("Load",action:load).controlSize(.mini)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()).help("Track time remaining")
Button("Load",action:load).controlSize(.mini).help("Load the selected DJ library track into deck \(deck.letter)")
}
DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22)
DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22).help("Track overview: click or drag to seek to a position in the song")
}
if deck.letter == "B" { artwork }
}.padding(10).frame(maxWidth:.infinity)
@@ -1712,57 +1804,94 @@ private struct DJConsoleWing: View {
@ObservedObject var system: DJSystem
var body: some View {
let face = DJDeckSurfaces(deck:deck,player:player,system:system)
HStack(spacing:10) {
if deck.letter == "B" { face.tonePanel }
if deck.letter == "A" { face.tempoStrip }
HStack(spacing:18) {
face.tempoStrip
Spacer(minLength:0)
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) {
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:21,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter))
Divider().frame(width:62)
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(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:95,height:95).background(Color(white:0.095),in:Circle()).allowsHitTesting(false)
}.frame(width:76,height:76).background(DJStyle.background,in:Circle()).allowsHitTesting(false)
}
if deck.letter == "A" { face.tonePanel }
if deck.letter == "B" { face.tempoStrip }
}.padding(.horizontal,9).frame(width:344).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
Spacer(minLength:0)
}.frame(maxWidth:.infinity).frame(height:130).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}
}
private struct DJConsoleTransport: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
private var playButton: some View {
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) }.foregroundStyle(player.playing ? Color.green : Color.white).accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play")
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) }
.foregroundStyle(player.playing ? Color.green : Color.white).help("Play or pause deck \(deck.letter)").accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play")
}
private var cueButton: some View {
Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22)
.help("Pause and return to the main cue at \(DJStyle.stamp(player.cuePosition)). Right-click to set or clear it.")
.contextMenu { Button("Set Cue Here") { player.setCue() };Button("Clear Main Cue") { player.clearCue() } }
}
private var cueButton: some View { Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22).contextMenu { Button("Set cue here") { player.setCue() } } }
private var loopButtons: some View {
HStack(spacing:0) {
Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }
Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle beat loop; requires confirmed BPM")
Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }
Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }.help("Halve the loop length; updates an active loop at its next boundary")
Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle a \(deck.loopBeats)-beat loop; requires confirmed BPM")
Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }.help("Double the loop length; updates an active loop at its next boundary")
}
}
private var headphones: some View { Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) }
private var headphones: some View {
Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white)
.help("Toggle deck \(deck.letter) headphone preview using the configured cue output")
}
private var clearMarkers: some View {
Menu {
Menu("Clear Individual Hot Cue") {
ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) }
}
Button("Clear All Hot Cues") { player.clearHotCues() }.disabled(player.hotCues.allSatisfy{$0 == nil})
Button("Clear Main Cue") { player.clearCue() }.disabled(player.cuePosition == 0)
Button("Clear Loop Markers") { player.clearLoop() }.disabled(player.loopIn == nil && player.loopOut == nil)
Divider()
Button("Clear All Cue & Loop Markers") { player.clearDeckMarkers() }
} label: { Image(systemName:"eraser") }
.help("Clear individual hot cues, all hot cues, the main cue, loop markers or all markers without unloading the track")
.accessibilityLabel("Deck \(deck.letter) clear markers")
}
var body: some View {
VStack(spacing:6) {
HStack(spacing:8) {
if deck.letter == "A" { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }
else { Spacer(minLength:0);headphones;loopButtons;cueButton;playButton }
}.controlSize(.regular)
HStack(spacing:6) {
if deck.letter == "B" { Spacer(minLength:0) }
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) } } }
Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() };Button("Loop ✎") { system.loopEditor = deck.letter }
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") }
if deck.letter == "A" { Spacer(minLength:0) }
HStack(spacing:8) { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }.controlSize(.regular)
HStack(spacing:4) {
ForEach(0..<4) { index in
Button("\(index+1)") {
if NSEvent.modifierFlags.contains(.option) { player.hotCue(index,clear:true) }
else { system.hotCue(index,deck:deck) }
}.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter))
.help(player.hotCues[index].map{"Hot cue \(index+1) at \(DJStyle.stamp($0)). Click to jump; Option-click or right-click to clear."} ?? "Click to set hot cue \(index+1) at the current position. Option-click or right-click clears it.")
.contextMenu { Button("Clear Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) }
}
Menu {
Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) }
Button("Set Cue Here") { player.setCue() }
Button("Clear Main Cue") { player.clearCue() }
Button("Edit Loop…") { system.loopEditor = deck.letter }
} label: { Image(systemName:"flag") }.help("Set, edit or clear the main cue; open the loop start/end editor")
Button("IN") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop start at the current position. Right-click to clear the loop markers.")
.contextMenu { Button("Clear Loop Markers") { player.clearLoop() } }
Button("OUT") { player.markLoopOut() }.foregroundStyle(player.loopOut == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop end and enable the loop. Set IN first; right-click to clear both markers.")
.contextMenu { Button("Clear Loop Markers") { player.clearLoop() } }
clearMarkers
Menu {
Button("Stop Deck") { player.stopDeck() }
Button("Exit Loop / Clear Loop Markers") { player.clearLoop() }
Button("Nudge Back") { player.jog(-0.05) }
Button("Nudge Forward") { player.jog(0.05) }
} label: { Image(systemName:"ellipsis") }.help("Stop the deck, exit its loop or nudge playback by 0.05 seconds")
Spacer(minLength:0)
}.controlSize(.mini)
}.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog)
}
}
private struct DJCentreWaveform: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@@ -1935,7 +2064,7 @@ private struct DJConsoleButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var enabled
init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active }
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))
.overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2))
.opacity(enabled ? 1 : 0.35)
@@ -2060,9 +2189,9 @@ private struct DJCueEditor: View {
Text(deck.song?.title ?? "No track").foregroundStyle(.secondary)
HStack { Text("Position in seconds");TextField("Seconds",value:$system.cueDraft,format:.number.precision(.fractionLength(2))).textFieldStyle(.roundedBorder).frame(width:100);Text("/ "+DJStyle.stamp(deck.player.duration)).foregroundStyle(.secondary) }
Slider(value:$system.cueDraft,in:0...max(0.001,deck.player.duration)).accessibilityLabel("Cue point position")
Button("Use Current Playhead") { system.cueDraft = deck.player.time }
Button("Use Current Playhead") { system.cueDraft = deck.player.time }.help("Use the current playback position as the main cue")
Text("Saving changes the cue point without moving or pausing playback. The CUE button pauses and returns to this position.").font(.caption).foregroundStyle(.secondary)
HStack { Button("Cancel") { system.cueEditing = false };Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction) }
HStack { Button("Cancel") { system.cueEditing = false }.help("Close without changing the cue");Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction).help("Save the main cue without moving or pausing playback") }
}.padding(24).frame(width:470)
}
}
@@ -2215,13 +2344,13 @@ private struct DJGraphicEQDialog: View {
Label("DJ Graphic Equaliser",systemImage:"slider.vertical.3").font(.title2.bold()).foregroundStyle(DJStyle.a)
Text("Shared by both decks and all tracks. Your deck EQ controls remain independent.").font(.callout).foregroundStyle(.secondary)
HStack {
Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch)
Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch).help("Enable or bypass the shared ten-band equaliser")
Spacer()
Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) {
ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) }
if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") }
}.frame(width:230)
Button("Reset") { system.chooseGraphicPreset("Flat") }
Button("Reset") { system.chooseGraphicPreset("Flat") }.help("Return every graphic EQ band to 0 dB")
}
HStack(alignment:.top,spacing:14) {
ForEach(0..<10,id:\.self) { index in
+63 -14
View File
@@ -26,6 +26,9 @@ struct Importer {
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true
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] {
if copyMedia { try ManagedMediaStorage.prepare(media) }
var state = ImportProgress()
@@ -73,6 +76,7 @@ struct Importer {
}
} else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) {
discovered.insert(url)
report("Scanning · \(discovered.count.formatted()) supported files found · \(url.lastPathComponent)")
}
} catch is CancellationError { throw CancellationError() }
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.
// Compare only same-size candidates, and hash each legacy file at most once.
var legacyBySize = [UInt64:[Song]]()
var existingChecked = 0
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 {
legacyBySize[size.uint64Value,default:[]].append(song)
}
@@ -222,9 +229,24 @@ struct Importer {
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)")
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 = try audioFingerprint(audio,cancelled:cancelled)
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
let hash: String
let graphicsHash: String?
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)
var sameGraphics = cdg == nil
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
}
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?
if let cdg {
let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL
@@ -246,12 +271,25 @@ struct Importer {
graphics = target.path
}
imported += 1
report("Reading tags: "+audio.lastPathComponent,force:imported == 1)
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 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)
try remember(audio,signature,id)
state.completed += 1
state.logLines.append("[IMPORTED] "+sourcePath(audio))
if let waveformCache {
@@ -274,29 +312,40 @@ struct Importer {
state.finished = true;report("Import complete",force:true)
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() }
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()
}
/// The importer runs on its worker queue. Bridge asynchronous tag loading here
/// 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 asset = AVURLAsset(url:url),loader = metadataLoader
let task = Task.detached(priority:.utility) {
defer { ready.signal() }
if let loader { result.set((try? await loader(url)) ?? [:]);return }
var tags = [AVMetadataKey:String]()
let items = (try? await AVURLAsset(url:url).load(.commonMetadata)) ?? []
let items = (try? await asset.load(.commonMetadata)) ?? []
for item in items {
if Task.isCancelled { break }
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 }
}
result.set(tags);ready.signal()
result.set(tags)
}
defer { task.cancel() }
while ready.wait(timeout:.now()+0.05) == .timedOut {
defer { task.cancel();asset.cancelLoading() }
let deadline = ProcessInfo.processInfo.systemUptime+max(0.01,metadataTimeout)
while ready.wait(timeout:.now()+0.02) == .timedOut {
if cancelled() { throw CancellationError() }
if ProcessInfo.processInfo.systemUptime >= deadline { return nil }
}
if cancelled() { throw CancellationError() }
return result.get()
+26 -14
View File
@@ -719,6 +719,7 @@ struct ImportProgressPanel: View {
var background: (() -> Void)? = nil
var isMove = false
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 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" }
@@ -755,7 +756,8 @@ struct ImportProgressPanel: View {
count("Skipped",value:progress.skipped,color:.orange)
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()
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)
@@ -785,7 +787,8 @@ struct ImportLogView: View {
var title = "Import Log"
@State private var follow = true
var body: some View {
let lines = progress.reportLines
let allLines = progress.reportLines
let lines = Array(allLines.suffix(300))
VStack(alignment:.leading,spacing:8) {
HStack {
Text(title).font(.headline)
@@ -793,13 +796,14 @@ struct ImportLogView: View {
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) }
}
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
ScrollView {
LazyVStack(alignment:.leading,spacing:7) {
ForEach(lines.indices,id:\.self) { index in
let line = lines[index]
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)
}.id(index)
}
@@ -807,7 +811,7 @@ struct ImportLogView: View {
}.padding(12)
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
.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 {
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()
footer.padding(.horizontal,18).padding(.vertical,8)
}.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) }
}
}
.sheet(isPresented:$waveformDetails) {
VStack(alignment:.leading,spacing:14) {
Text("Waveform Analysis").font(.title2.bold())
ScrollView { Text(model.waveformJob?.messages.joined(separator:"\n\n") ?? "").font(.callout).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:300)
HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:560)
.sheet(isPresented:Binding(get:{model.waveformJob != nil},set:{if !$0 { model.dismissWaveformProgress() }})) {
if let progress = model.waveformJob {
VStack(alignment:.leading,spacing:20) {
Label("Karaoke Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(.cyan)
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)
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:$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.
final class WaveformWorkload: @unchecked Sendable {
static let shared = WaveformWorkload()
@@ -172,9 +199,20 @@ extension WaveformBatch {
let tracks = groups[next];next += 1;active += 1
state.current = tracks.first?.title ?? "Preparing waveforms…"
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 }
active -= 1
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
}
func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil }
func clearHotCues() { guard canJog else { return };hotCues = [nil,nil,nil,nil] }
func clearCue() { guard canJog else { return };cuePosition = 0 }
func clearDeckMarkers() { clearHotCues();clearCue();clearLoop() }
func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) }
private var graphicsGeneration = UUID()
private var graphicsTask: Task<Void,Never>?
@@ -433,24 +437,26 @@ import CoreAudio
let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false
let directory = waveformCacheDirectory
waveformTask = Task.detached(priority:.utility) { [weak self] in
let result: Result<AudioWaveform.Analysis,Error>
var cached = false,saved = false
let result: Result<(AudioWaveform.Analysis,Bool,Bool),Error>
do {
if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) {
result = .success(analysis);cached = true;saved = true
} else {
let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled })
if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil }
result = .success(analysis)
}
result = .success(try await WaveformWorker.run { cancelled in
if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) {
return (analysis,true,true)
}
let analysis = try AudioWaveform.analyse(url:url,cancelled:cancelled)
if cancelled() { throw CancellationError() }
let saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil
return (analysis,false,saved)
})
} catch { result = .failure(error) }
guard !Task.isCancelled else { return }
let loadedFromCache = cached,analysisSaved = saved
await MainActor.run { [weak self] in
guard let self,self.waveformGeneration == token else { return }
self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved
self.waveformLoading = false
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
}
}
+205 -9
View File
@@ -2074,14 +2074,14 @@ extension KJamsCloneTests {
XCTAssertEqual(Array(leds.prefix(9)),[0x90,0,127,0x90,1,1,0x90,2,127])
}
@MainActor func testEQAndFilterDoubleClickActuallyResetKnobs() async throws {
var values = [6.0,-6.0,0.75]
let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<3,id:\.self) { i in DJKnob(label:["HIGH","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 2 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:240,height:80))
host.frame = NSRect(x:0,y:0,width:240,height:80)
var values = [6.0,3.0,-6.0,0.75]
let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<4,id:\.self) { i in DJKnob(label:["HIGH","MID","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 3 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:320,height:80))
host.frame = NSRect(x:0,y:0,width:320,height:80)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.contentView = host
let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded()
for index in 0..<3 {
for index in 0..<4 {
for click in 1...2 {
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:40+index*80,y:46),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:click,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
@@ -2089,7 +2089,21 @@ extension KJamsCloneTests {
}
try await Task.sleep(nanoseconds:100_000_000)
}
XCTAssertEqual(values,[0,0,0])
XCTAssertEqual(values,[0,0,0,0])
func controls(_ view: NSView) -> [DJRotaryControl] { (view as? DJRotaryControl).map{[$0]} ?? view.subviews.flatMap{controls($0)} }
let knobs = controls(host)
XCTAssertEqual(knobs.count,4)
for knob in knobs {
let centre = knob.convert(NSPoint(x:knob.bounds.midX,y:14),to:nil)
let down = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDown,location:centre,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:1))
knob.mouseDown(with:down)
let drag = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDragged,location:NSPoint(x:centre.x,y:centre.y+12),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:1))
knob.mouseDragged(with:drag);XCTAssertNotEqual(knob.value,0)
let label = knob.convert(NSPoint(x:knob.bounds.midX,y:35),to:nil)
let doubleClick = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDown,location:label,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:2,pressure:1))
knob.mouseDown(with:doubleClick);XCTAssertEqual(knob.value,0)
}
XCTAssertEqual(values,[0,0,0,0],"Reset on the label also updates each audio parameter after a drag")
}
}
@@ -2358,10 +2372,11 @@ extension KJamsCloneTests {
}
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}))
XCTAssertGreaterThan(libraryScroll.bounds.height,380,"DJ library grows into the remaining workspace height")
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")) }
XCTAssertGreaterThan(libraryScroll.bounds.height,250,"The resizable DJ library retains a useful track viewport")
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)
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)
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)
let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1})
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 {
let policy = WaveformWorkload.shared,id = UUID()
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.
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.