removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-07 11:15:40 +00:00
parent ec601f3544
commit 9d0d1cccc8
5 changed files with 145 additions and 34 deletions
+8 -16
View File
@@ -373,6 +373,7 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var importLogVisible = false
@Published private(set) var importing = false
private var importTask: Task<Void,Never>?
private var importGeneration = UUID()
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
@@ -418,6 +419,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let importID = UUID();importGeneration = importID
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
@@ -433,15 +435,15 @@ struct DJAudioDevice: Identifiable, Equatable {
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
}
}
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,continueOnUnreadableAudio:true).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
Task { @MainActor [weak self] in if self?.importing == true,self?.importGeneration == importID { self?.importProgress = value } }
}
} catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest,loadedIDs = importedIDs
await MainActor.run { [weak self] in
guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result
guard let self,self.importGeneration == importID else { return };self.importing = false;self.importTask = nil;self.importProgress = result
self.lastImportReport = result
self.perform { try self.refresh() }
if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } }
@@ -451,7 +453,7 @@ struct DJAudioDevice: Identifiable, Equatable {
}
}
}
func cancelImport() { queuedFileLoads = [];importTask?.cancel() }
func cancelImport() { queuedFileLoads = [];importTask?.cancel();if importing { importProgress?.current = "Cancelling…" } }
func continueFileLoads() {
guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return }
let next = queuedFileLoads.removeFirst()
@@ -1121,17 +1123,6 @@ struct DJConsole: View {
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(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top)
.background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark)
@@ -1141,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)
}
}
@@ -1205,6 +1196,7 @@ struct DJConsole: View {
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 { 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).help("Stop independent headphone preview") }
+63 -14
View File
@@ -26,6 +26,9 @@ struct Importer {
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true
var waveformCache: URL? = nil
var continueOnUnreadableAudio = false
var metadataTimeout: TimeInterval = 2
var metadataLoader: (@Sendable (URL) async throws -> [AVMetadataKey:String])? = nil
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
if copyMedia { try ManagedMediaStorage.prepare(media) }
var state = ImportProgress()
@@ -73,6 +76,7 @@ struct Importer {
}
} else if values.isRegularFile == true,supported.contains(url.pathExtension.lowercased()) {
discovered.insert(url)
report("Scanning · \(discovered.count.formatted()) supported files found · \(url.lastPathComponent)")
}
} catch is CancellationError { throw CancellationError() }
catch { throw LibraryError.message("Could not scan \(url.path): \(error.localizedDescription)") }
@@ -85,7 +89,10 @@ struct Importer {
// Older libraries may contain indexed tracks without stored fingerprints.
// Compare only same-size candidates, and hash each legacy file at most once.
var legacyBySize = [UInt64:[Song]]()
var existingChecked = 0
for song in try library.songs() where song.fingerprint.isEmpty {
if cancelled() { throw CancellationError() }
existingChecked += 1;report("Checking existing library · \(existingChecked.formatted()) tracks · \(song.title)")
if let info = try? FileManager.default.attributesOfItem(atPath:song.audio),let size = info[.size] as? NSNumber,size.uint64Value > 0 {
legacyBySize[size.uint64Value,default:[]].append(song)
}
@@ -222,9 +229,24 @@ struct Importer {
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (already imported)")
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
}
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
let hash = try audioFingerprint(audio,cancelled:cancelled)
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
let hash: String
let graphicsHash: String?
do {
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
report("Checking audio: "+audio.lastPathComponent)
if continueOnUnreadableAudio { _ = try AVAudioFile(forReading:audio) }
hash = try audioFingerprint(audio,cancelled:cancelled,progress:{ bytes in
report("Reading "+audio.lastPathComponent+" · "+ByteCountFormatter.string(fromByteCount:bytes,countStyle:.file))
})
graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
} catch is CancellationError { throw CancellationError() }
catch {
guard continueOnUnreadableAudio else { throw error }
state.completed += 1;state.skipped += 1
state.logLines.append("[SKIPPED] "+sourcePath(audio)+" (unreadable audio: "+error.localizedDescription+")")
report("Skipped unreadable file: "+audio.lastPathComponent,force:state.completed == 1)
continue
}
let duplicate = try duplicateWithFingerprint(hash,audio:audio)
var sameGraphics = cdg == nil
if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) {
@@ -238,7 +260,10 @@ struct Importer {
report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue
}
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
if copy { try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled) }
if copy {
report("Copying: "+audio.lastPathComponent)
try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled)
}
var graphics: String?
if let cdg {
let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL
@@ -246,12 +271,25 @@ struct Importer {
graphics = target.path
}
imported += 1
report("Reading tags: "+audio.lastPathComponent,force:imported == 1)
let metadata = try audioMetadata(audio,cancelled:cancelled)
func tag(_ key: AVMetadataKey) -> String { metadata[key] ?? "" }
if metadata == nil { state.logLines.append("[WARNING] "+sourcePath(audio)+" (tag read timed out; imported using its filename)") }
func tag(_ key: AVMetadataKey) -> String { metadata?[key] ?? "" }
let taggedTitle = tag(.commonKeyTitle)
let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy)
let duration = audioDuration(audio)
report("Saving track: "+audio.lastPathComponent)
try library.execute("SAVEPOINT kjmix_import_track")
let id: Int64
do {
id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:duration,fingerprint:hash,preferIncomingFile:copy)
try remember(audio,signature,id)
try library.execute("RELEASE SAVEPOINT kjmix_import_track")
} catch {
try? library.execute("ROLLBACK TO SAVEPOINT kjmix_import_track")
try? library.execute("RELEASE SAVEPOINT kjmix_import_track")
throw error
}
songIDs.insert(id)
try remember(audio,signature,id)
state.completed += 1
state.logLines.append("[IMPORTED] "+sourcePath(audio))
if let waveformCache {
@@ -274,29 +312,40 @@ struct Importer {
state.finished = true;report("Import complete",force:true)
return Array(songIDs)
}
private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String {
private func audioFingerprint(_ url: URL,cancelled: () -> Bool,progress: (Int64) -> Void = {_ in}) throws -> String {
let file = try FileHandle(forReadingFrom:url);defer { try? file.close() }
var hasher = SHA256()
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) }
var bytes: Int64 = 0
while true {
if cancelled() { throw CancellationError() }
guard let data = try file.read(upToCount:1_048_576),!data.isEmpty else { break }
hasher.update(data:data);bytes += Int64(data.count);progress(bytes)
}
return hasher.finalize().map { String(format:"%02x",$0) }.joined()
}
/// The importer runs on its worker queue. Bridge asynchronous tag loading here
/// so the existing synchronous transaction can retain its cancellation semantics.
private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String] {
private func audioMetadata(_ url: URL,cancelled: () -> Bool) throws -> [AVMetadataKey:String]? {
if cancelled() { throw CancellationError() }
let result = ImportMetadataResult(),ready = DispatchSemaphore(value:0)
let asset = AVURLAsset(url:url),loader = metadataLoader
let task = Task.detached(priority:.utility) {
defer { ready.signal() }
if let loader { result.set((try? await loader(url)) ?? [:]);return }
var tags = [AVMetadataKey:String]()
let items = (try? await AVURLAsset(url:url).load(.commonMetadata)) ?? []
let items = (try? await asset.load(.commonMetadata)) ?? []
for item in items {
if Task.isCancelled { break }
guard let key = item.commonKey,[AVMetadataKey.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher].contains(key),tags[key] == nil else { continue }
if let value = try? await item.load(.stringValue) { tags[key] = value }
}
result.set(tags);ready.signal()
result.set(tags)
}
defer { task.cancel() }
while ready.wait(timeout:.now()+0.05) == .timedOut {
defer { task.cancel();asset.cancelLoading() }
let deadline = ProcessInfo.processInfo.systemUptime+max(0.01,metadataTimeout)
while ready.wait(timeout:.now()+0.02) == .timedOut {
if cancelled() { throw CancellationError() }
if ProcessInfo.processInfo.systemUptime >= deadline { return nil }
}
if cancelled() { throw CancellationError() }
return result.get()
+8 -4
View File
@@ -719,6 +719,7 @@ struct ImportProgressPanel: View {
var background: (() -> Void)? = nil
var isMove = false
var activity: String? = nil
var waveformStatus: BackgroundWaveformStatus? = nil
private var fraction: Double { progress.total > 0 ? min(1,max(0,Double(progress.completed)/Double(progress.total))) : 0 }
private var accent: Color { progress.failure != nil || progress.cancelled ? .orange : progress.finished ? .green : .cyan }
private var title: String { if let activity { return progress.finished ? (progress.cancelled ? "Cancelled" : progress.failure == nil ? "Library Updated" : "Library Needs Attention") : activity };if isMove { return progress.finished ? (progress.cancelled ? "Move Cancelled" : progress.failure == nil ? "Move Complete" : "Move Needs Attention") : "Moving Media and Waveforms" };return progress.finished ? (progress.cancelled ? "Import Cancelled" : progress.failure == nil ? "Import Complete" : "Import Needs Attention") : "Importing Music" }
@@ -755,7 +756,8 @@ struct ImportProgressPanel: View {
count("Skipped",value:progress.skipped,color:.orange)
count("Remaining",value:progress.total > 0 ? max(0,progress.total-progress.completed) : -1,color:.cyan)
}
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:260)
if let waveformStatus { waveformStatus }
ImportLogView(progress:progress,title:activity != nil ? "Library Log" : isMove ? "Transfer Log" : "Import Log").frame(height:waveformStatus == nil ? 260 : 200)
Divider()
HStack {
Text(progress.finished ? "Review the log or copy it before closing." : isMove ? "Copies are verified before originals are removed." : "You can cancel safely; completed tracks stay in your library.").font(.caption).foregroundStyle(.secondary)
@@ -785,7 +787,8 @@ struct ImportLogView: View {
var title = "Import Log"
@State private var follow = true
var body: some View {
let lines = progress.reportLines
let allLines = progress.reportLines
let lines = Array(allLines.suffix(300))
VStack(alignment:.leading,spacing:8) {
HStack {
Text(title).font(.headline)
@@ -793,13 +796,14 @@ struct ImportLogView: View {
if !progress.finished { Toggle("Follow log",isOn:$follow).toggleStyle(.checkbox).controlSize(.small) }
Button("Copy Log") { NSPasteboard.general.clearContents();NSPasteboard.general.setString(progress.logText,forType:.string) }
}
if allLines.count > lines.count { Text("Showing the latest \(lines.count.formatted()) of \(allLines.count.formatted()) entries. Copy Log includes the full report.").font(.caption).foregroundStyle(.secondary) }
ScrollViewReader { reader in
ScrollView {
LazyVStack(alignment:.leading,spacing:7) {
ForEach(lines.indices,id:\.self) { index in
let line = lines[index]
HStack(alignment:.top,spacing:8) {
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : line.hasPrefix("[SKIPPED]") ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
Circle().fill(line.hasPrefix("[FAILED]") ? Color.red : (line.hasPrefix("[SKIPPED]") || line.hasPrefix("[WARNING]")) ? Color.orange : Color.green).frame(width:5,height:5).padding(.top,5)
Text(line).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
}.id(index)
}
@@ -807,7 +811,7 @@ struct ImportLogView: View {
}.padding(12)
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
.onStudioChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
.onStudioChange(of:allLines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
}
}
}