Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3007b8bd | ||
|
|
9d0d1cccc8 | ||
|
|
ec601f3544 | ||
|
|
a1c25cdb23 |
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user