removed unwanted bar now with waveforms, major updates

This commit is contained in:
Mark Gregory
2026-11-06 13:09:24 +00:00
parent 7b45184dff
commit f6368e4ae7
7 changed files with 243 additions and 41 deletions
+61 -6
View File
@@ -172,6 +172,51 @@ struct DJAudioDevice: Identifiable, Equatable {
}
@MainActor final class DJSystem: ObservableObject {
@Published private(set) var mediaDirectory: URL
@Published private(set) var waveformDirectory: URL
@Published private(set) var movingStorage = false
@Published var storageProgress: ImportProgress?
private var storageTask: Task<Void,Never>?
private var storageAccess: URL?
private func loadStorage() throws {
let location = try library.flatMap{try DJStorageMove.location(in:$0)}
var folder = location.map{URL(fileURLWithPath:$0.path)} ?? root
if let bookmark = location?.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { folder = resolved } }
if storageAccess != folder {
storageAccess?.stopAccessingSecurityScopedResource();storageAccess = folder.startAccessingSecurityScopedResource() ? folder : nil
}
mediaDirectory = folder.appendingPathComponent("Media");waveformDirectory = folder.appendingPathComponent("Waveforms")
for player in [a.player,b.player,preview] { player.useWaveformCache(at:waveformDirectory) }
}
func chooseStorage() {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop both decks and headphone preview before moving DJ files.";return }
let panel = NSOpenPanel();panel.title = "Move DJ Media and Waveforms";panel.prompt = "Move Here"
panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true
panel.message = "Choose a folder on your external drive. DJ media and saved waveforms move into Media and Waveforms folders here. The database and playlists stay on this Mac."
panel.begin { [weak self] response in guard response == .OK,let url = panel.url else { return };Task { @MainActor in self?.moveStorage(to:url) } }
}
func moveStorage(to destination: URL) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
a.unload();b.unload();stopPreview();movingStorage = true;storageProgress = .init()
storageTask = Task.detached(priority:.utility) { [weak self] in
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in
latest = value;Task { @MainActor [weak self] in self?.storageProgress = value }
} } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" }
catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
guard let self else { return };self.movingStorage = false;self.storageTask = nil
self.perform { try self.loadStorage();try self.refresh() }
for (id,deck) in [(loadedA,self.a),(loadedB,self.b)] { if let id,let song = self.songs.first(where:{$0.id == id}) { self.perform { try deck.load(song) } } }
self.storageProgress = result
}
}
}
func cancelStorageMove() { storageTask?.cancel() }
@Published var namesBusy = false
private var graphicSaveTask: Task<Void,Never>?
@Published var graphicEQ = DJGraphicEQSettings() { didSet {
@@ -193,10 +238,10 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published private(set) var generatingWaveforms = false
private var waveformTask: Task<Void,Never>?
func generateWaveforms(_ ids: Set<Int64>? = nil,force: Bool = false) {
guard !generatingWaveforms,!importing else { error = "Wait for the current import or waveform job to finish first.";return }
guard !generatingWaveforms,!importing,!movingStorage else { error = "Wait for the current import or waveform job to finish first.";return }
let tracks = songs.filter { ids == nil || ids!.contains($0.id) }
guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return }
let cache = root.appendingPathComponent("Waveforms")
let cache = waveformDirectory
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformTask = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in
@@ -253,6 +298,7 @@ struct DJAudioDevice: Identifiable, Equatable {
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")
mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms")
a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms"))
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
@@ -266,7 +312,7 @@ struct DJAudioDevice: Identifiable, Equatable {
}.store(in:&syncObservers)
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try loadStorage();try refresh()
if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized }
applyGraphicEQ()
favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)})
@@ -292,9 +338,9 @@ struct DJAudioDevice: Identifiable, Equatable {
func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } }
func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) }
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!urls.isEmpty else { return }
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms"),replacedSong = loadInto.flatMap{channel($0).song?.id}
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
@@ -326,7 +372,7 @@ struct DJAudioDevice: Identifiable, Equatable {
requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
}
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() }
deinit { importTask?.cancel();waveformTask?.cancel() }
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource() }
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
@@ -341,6 +387,7 @@ struct DJAudioDevice: Identifiable, Equatable {
} else { load(song,into:target) }
}
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return }
perform {
let deck = channel(letter)
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
@@ -1609,6 +1656,14 @@ struct DJPreferencesPane: View {
}
private var library: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Media and Waveform Storage") {
VStack(alignment:.leading,spacing:14) {
LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) }
LabeledContent("Waveforms") { Text(system.waveformDirectory.path).textSelection(.enabled) }
Button("Move Media and Waveforms…") { system.chooseStorage() }.disabled(system.movingStorage || system.importing || system.generatingWaveforms)
Text("Choose a folder on an external drive. Files are copied and verified before originals are removed. The DJ database and playlists stay on this Mac. Stop playback before moving files.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Independent DJ Collection") {
VStack(alignment:.leading,spacing:14) {
LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)")