removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-06 15:21:41 +00:00
parent c39c2bd757
commit bb136ee202
15 changed files with 239 additions and 437 deletions
-295
View File
@@ -1,295 +0,0 @@
import SwiftUI
import MusicKit
struct AppleMusicTrack: Identifiable, Equatable {
let id: String
let title: String
let artist: String
let album: String
let duration: Double
let artwork: URL?
}
struct AppleMusicPlaylist: Identifiable, Equatable { let id: String;let name: String }
struct AppleMusicPage { let tracks: [AppleMusicTrack];let hasMore: Bool }
enum AppleMusicAccess { case notDetermined,authorized,denied,restricted }
struct AppleMusicQueueItem: Identifiable, Equatable { let id: String;let title: String;let artist: String;let current: Bool }
struct AppleMusicPlayback: Equatable { var title = "";var artist = "";var playing = false;var position: Double = 0;var duration: Double = 0;var queue = [AppleMusicQueueItem]() }
@MainActor protocol AppleMusicClient: AnyObject {
var access: AppleMusicAccess { get }
func authorize() async -> AppleMusicAccess
func subscribed() async throws -> Bool
func playlists() async throws -> [AppleMusicPlaylist]
func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage
func play(_ id: String) async throws
func enqueue(_ id: String) async throws
func resume() async throws
func pause()
func stop()
func seek(_ seconds: Double)
func next() async throws
var playback: AppleMusicPlayback { get }
}
/// Public MusicKit playback stays inside Apple's player; protected streams are
/// never passed to AVAudioFile, waveform analysis or the local deck DSP graph.
@available(macOS 14.0, *) @MainActor final class NativeAppleMusicClient: AppleMusicClient {
private var tracks = [String:MusicKit.Song]()
private var playlistItems = [String:MusicKit.Playlist]()
private var musicPlayer: ApplicationMusicPlayer { .shared }
var access: AppleMusicAccess {
switch MusicAuthorization.currentStatus {
case .authorized:return .authorized
case .denied:return .denied
case .restricted:return .restricted
default:return .notDetermined
}
}
func authorize() async -> AppleMusicAccess { _ = await MusicAuthorization.request();return access }
func subscribed() async throws -> Bool { try await MusicSubscription.current.canPlayCatalogContent }
private func remember(_ songs: [MusicKit.Song]) -> [AppleMusicTrack] {
songs.map { song in
tracks[song.id.rawValue] = song
return AppleMusicTrack(id:song.id.rawValue,title:song.title,artist:song.artistName,album:song.albumTitle ?? "",duration:song.duration ?? 0,artwork:song.artwork?.url(width:80,height:80))
}
}
func playlists() async throws -> [AppleMusicPlaylist] {
var request = MusicLibraryRequest<MusicKit.Playlist>();request.limit = 100
var result = [AppleMusicPlaylist]()
while true {
try Task.checkCancellation()
let response = try await request.response()
for item in response.items { playlistItems[item.id.rawValue] = item;result.append(.init(id:item.id.rawValue,name:item.name)) }
if !response.items.hasNextBatch { break };request.offset += response.items.count
}
return result
}
func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage {
if !query.isEmpty {
var request = MusicCatalogSearchRequest(term:query,types:[MusicKit.Song.self]);request.limit = 50;request.offset = offset
let response = try await request.response()
return .init(tracks:remember(Array(response.songs)),hasMore:response.songs.hasNextBatch)
}
if let playlist,let item = playlistItems[playlist] {
let detailed = try await item.with([.tracks])
guard var collection = detailed.tracks else { return .init(tracks:[],hasMore:false) }
var all = Array(collection)
while all.count < offset+50,collection.hasNextBatch,let next = try await collection.nextBatch(limit:50) {
try Task.checkCancellation();collection = next;all.append(contentsOf:next)
}
let songs = all.dropFirst(offset).prefix(50).compactMap { track -> MusicKit.Song? in
if case .song(let song) = track { return song };return nil
}
return .init(tracks:remember(songs),hasMore:all.count > offset+50 || collection.hasNextBatch)
}
var request = MusicLibraryRequest<MusicKit.Song>();request.limit = 50;request.offset = offset
let response = try await request.response()
return .init(tracks:remember(Array(response.items)),hasMore:response.items.hasNextBatch)
}
private func song(_ id: String) throws -> MusicKit.Song {
guard let song = tracks[id] else { throw LibraryError.message("Refresh Apple Music and select the track again.") };return song
}
func play(_ id: String) async throws { musicPlayer.queue = .init(for:[try song(id)]);try await musicPlayer.play() }
func enqueue(_ id: String) async throws { try await musicPlayer.queue.insert(song(id),position:.tail) }
func resume() async throws { try await musicPlayer.play() }
func pause() { musicPlayer.pause() }
func stop() { musicPlayer.stop() }
func seek(_ seconds: Double) { musicPlayer.playbackTime = max(0,seconds) }
func next() async throws { try await musicPlayer.skipToNextEntry() }
var playback: AppleMusicPlayback {
let entry = musicPlayer.queue.currentEntry
var duration: Double = 0
if case .song(let song) = entry?.item { duration = song.duration ?? 0 }
let queue = musicPlayer.queue.entries.map { AppleMusicQueueItem(id:$0.id,title:$0.title,artist:$0.subtitle ?? "",current:$0.id == entry?.id) }
return .init(title:entry?.title ?? "",artist:entry?.subtitle ?? "",playing:musicPlayer.state.playbackStatus == .playing,position:musicPlayer.playbackTime,duration:duration,queue:queue)
}
}
@available(macOS 14.0, *) @MainActor final class AppleMusicTransportState: ObservableObject {
@Published var value = AppleMusicPlayback()
}
@available(macOS 14.0, *) @MainActor final class AppleMusicSession: ObservableObject {
@Published private(set) var access: AppleMusicAccess
@Published private(set) var canPlay = false
@Published private(set) var tracks = [AppleMusicTrack]()
@Published private(set) var playlists = [AppleMusicPlaylist]()
@Published private(set) var busy = false
@Published private(set) var loadingMore = false
@Published private(set) var hasMore = false
let transport = AppleMusicTransportState()
var playback: AppleMusicPlayback {
get { transport.value }
set { if newValue != transport.value { transport.value = newValue } }
}
@Published var error: String?
@Published var query = ""
@Published var playlist: String?
private let client: AppleMusicClient
private var browseTask: Task<Void,Never>?
private var playbackTask: Task<Void,Never>?
private var generation = UUID()
init(client: AppleMusicClient? = nil) { let client = client ?? NativeAppleMusicClient();self.client = client;access = client.access }
deinit { browseTask?.cancel();playbackTask?.cancel() }
func connect() async {
guard !busy else { return };browseTask?.cancel();generation = UUID();canPlay = false;tracks = [];busy = true;error = nil
access = await client.authorize()
guard access == .authorized else {
busy = false;error = access == .restricted ? "Apple Music access is restricted on this Mac." : "Allow KJMix Pro in System Settings → Privacy & Security → Media & Apple Music.";return
}
do { canPlay = try await client.subscribed();playlists = try await client.playlists() }
catch { self.error = "Apple Music could not connect. Check your internet connection, Apple Music subscription and this app’s MusicKit App Service configuration. \(error.localizedDescription)" }
busy = false
if error == nil { reload() }
}
func reload() {
browseTask?.cancel();generation = UUID();let token = generation
tracks = [];hasMore = false;loadingMore = false
guard access == .authorized else { return }
busy = true;error = nil
let query = query.trimmingCharacters(in:.whitespacesAndNewlines),playlist = playlist
browseTask = Task { [weak self] in
guard let self else { return }
do {
let page = try await client.browse(query:query,playlist:playlist,offset:0)
guard !Task.isCancelled,generation == token else { return }
tracks = page.tracks;hasMore = page.hasMore;busy = false
} catch { guard generation == token,!Task.isCancelled else { return };self.error = error.localizedDescription;busy = false }
}
}
func more() {
guard hasMore,!busy,!loadingMore else { return };loadingMore = true
let token = generation,offset = tracks.count,query = query.trimmingCharacters(in:.whitespacesAndNewlines),playlist = playlist
browseTask = Task { [weak self] in
guard let self else { return }
do {
let page = try await client.browse(query:query,playlist:playlist,offset:offset)
guard !Task.isCancelled,generation == token else { return }
let existing = Set(tracks.map(\.id));tracks += page.tracks.filter{!existing.contains($0.id)};hasMore = page.hasMore;loadingMore = false
} catch { guard generation == token,!Task.isCancelled else { return };self.error = error.localizedDescription;loadingMore = false }
}
}
func play(_ track: AppleMusicTrack) async {
guard canPlay else { error = "An active Apple Music subscription is required for full playback.";return }
do { try await client.play(track.id);observePlayback() } catch { self.error = error.localizedDescription }
}
func enqueue(_ track: AppleMusicTrack) async {
guard canPlay else { error = "An active Apple Music subscription is required for full playback.";return }
do { try await client.enqueue(track.id);observePlayback() } catch { self.error = error.localizedDescription }
}
func toggle() async {
do { if playback.playing { client.pause() } else { try await client.resume() };observePlayback() } catch { self.error = error.localizedDescription }
}
func pause() { client.pause();playback = client.playback }
func stop() { client.stop();playback = client.playback;playbackTask?.cancel();playbackTask = nil }
func seek(_ value: Double) { client.seek(min(playback.duration,max(0,value)));playback = client.playback }
func next() async { do { try await client.next();observePlayback() } catch { self.error = error.localizedDescription } }
private func observePlayback() {
playback = client.playback
guard playbackTask == nil else { return }
playbackTask = Task { [weak self] in
while !Task.isCancelled {
do { try await Task.sleep(nanoseconds:250_000_000) } catch { return }
guard let self else { return };self.playback = self.client.playback
}
}
}
}
@available(macOS 14.0, *) struct AppleMusicBrowser: View {
@ObservedObject var session: AppleMusicSession
@State private var pendingPlay: AppleMusicTrack?
@State private var pendingResume = false
let hasPlayingDeck: () -> Bool
let stopDecks: () -> Void
private func requestPlay(_ track: AppleMusicTrack) {
if hasPlayingDeck() { pendingPlay = track } else { Task { await session.play(track) } }
}
var body: some View {
VStack(spacing:0) {
HStack(spacing:14) {
Label("Apple Music",systemImage:"music.note").font(.title3.bold()).foregroundStyle(.pink)
Picker("Apple Music library",selection:$session.playlist) {
Text("My Library").tag(nil as String?)
ForEach(session.playlists) { Text($0.name).tag(Optional($0.id)) }
}.labelsHidden().frame(maxWidth:200).disabled(session.access != .authorized)
Spacer()
StudioSearchField(placeholder:"Search Apple Music catalog",text:$session.query).frame(maxWidth:320)
.onSubmit { session.reload() }
Button("Search") { session.reload() }.buttonStyle(LibraryActionButtonStyle(tint:.pink)).disabled(session.access != .authorized)
Button(session.access == .authorized ? "Refresh Connection" : "Connect Apple Music") { Task { await session.connect() } }
.buttonStyle(LibraryActionButtonStyle(tint:.pink,prominent:true)).disabled(session.busy)
}.padding(14)
if let error = session.error { HStack { Image(systemName:"exclamationmark.circle.fill").foregroundStyle(.orange);Text(error).font(.callout);Spacer();Button("Dismiss") { session.error = nil } }.padding(12).background(Color.orange.opacity(0.1)) }
if session.busy { ProgressView("Loading Apple Music…").padding() }
if session.access != .authorized {
VStack(spacing:12) {
Image(systemName:"music.note.list").font(.system(size:34)).foregroundStyle(.pink)
Text("Your Apple Music library and playlists").font(.headline)
Text("Connect to browse your library, search the catalog and play through Apple Music.").foregroundStyle(.secondary)
}.frame(maxWidth:.infinity,maxHeight:.infinity)
} else {
Table(session.tracks) {
TableColumn("Track") { track in
HStack(spacing:10) {
AsyncImage(url:track.artwork) { image in image.resizable().scaledToFill() } placeholder: { Image(systemName:"music.note").frame(maxWidth:.infinity,maxHeight:.infinity).background(Color.pink.opacity(0.12)) }.frame(width:32,height:32).clipShape(RoundedRectangle(cornerRadius:5))
Text(track.title).lineLimit(1)
}.padding(.vertical,3).onTapGesture(count:2) { requestPlay(track) }
.contextMenu { Button("Play in Apple Music") { requestPlay(track) };Button("Add to Apple Music Queue") { Task { await session.enqueue(track) } } }
}.width(min:180,ideal:300)
TableColumn("Artist",value:\.artist).width(min:100,ideal:180)
TableColumn("Album",value:\.album).width(min:100,ideal:180)
TableColumn("Time") { Text(Self.time(track:$0.duration)).monospacedDigit() }.width(60)
TableColumn("Playback") { track in
HStack { Button { requestPlay(track) } label: { Image(systemName:"play.fill") };Button { Task { await session.enqueue(track) } } label: { Image(systemName:"text.badge.plus") } }.disabled(!session.canPlay)
}.width(90)
}
if session.hasMore { Button(session.loadingMore ? "Loading…" : "Load More") { session.more() }.disabled(session.loadingMore).padding(8) }
}
Divider()
AppleMusicTransportBar(session:session,toggle:{
if hasPlayingDeck(),!session.playback.playing { pendingResume = true }
else { Task { await session.toggle() } }
})
Text("MusicKit playback uses the system audio output. Deck EQ, sync, loops, waveform analysis and recording apply to local tracks.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,14).padding(.bottom,10)
}.background(Color(red:0.095,green:0.11,blue:0.135)).clipShape(RoundedRectangle(cornerRadius:10))
.onChange(of:session.playlist) { _ in session.query = "";session.reload() }
.onChange(of:session.query) { value in if value.isEmpty { session.reload() } }
.alert("Stop deck playback?",isPresented:Binding(get:{pendingPlay != nil || pendingResume},set:{if !$0 { pendingPlay = nil;pendingResume = false }})) {
Button("Cancel",role:.cancel) { pendingPlay = nil;pendingResume = false }
Button("Stop Decks & Play") {
stopDecks()
let track = pendingPlay;pendingPlay = nil;pendingResume = false
Task { if let track { await session.play(track) } else { await session.toggle() } }
}
} message: { Text("Apple Music uses its own player. Stop the playing decks before starting Apple Music?") }
}
static func time(track: Double) -> String { guard track.isFinite else { return "0:00" };let seconds = max(0,Int(track));return String(format:"%d:%02d",seconds/60,seconds%60) }
}
@available(macOS 14.0, *) private struct AppleMusicTransportBar: View {
let session: AppleMusicSession
let toggle: () -> Void
@State private var queueVisible = false
@ObservedObject var transport: AppleMusicTransportState
init(session: AppleMusicSession,toggle: @escaping () -> Void) { self.session = session;self.toggle = toggle;transport = session.transport }
var body: some View {
HStack(spacing:14) {
Button(action:toggle) { Image(systemName:session.playback.playing ? "pause.fill" : "play.fill") }.disabled(session.playback.title.isEmpty)
Button { session.stop() } label: { Image(systemName:"stop.fill") }.disabled(session.playback.title.isEmpty)
Button { Task { await session.next() } } label: { Image(systemName:"forward.end.fill") }.disabled(session.playback.title.isEmpty)
VStack(alignment:.leading,spacing:3) { Text(session.playback.title.isEmpty ? "Choose an Apple Music track" : session.playback.title).fontWeight(.semibold).lineLimit(1);Text(session.playback.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }.frame(minWidth:160,maxWidth:300,alignment:.leading)
Text(AppleMusicBrowser.time(track:session.playback.position)).monospacedDigit()
Slider(value:Binding(get:{min(session.playback.duration,max(0,session.playback.position))},set:{session.seek($0)}),in:0...max(1,session.playback.duration)).tint(.pink).disabled(session.playback.duration <= 0)
Text(AppleMusicBrowser.time(track:session.playback.duration)).monospacedDigit()
Button { queueVisible = true } label: { Label("Queue",systemImage:"text.line.first.and.arrowtriangle.forward") }
}.buttonStyle(.bordered).padding(14)
.sheet(isPresented:$queueVisible) {
VStack(alignment:.leading,spacing:14) {
HStack { Text("Apple Music Queue").font(.title2.bold());Spacer();Button("Done") { queueVisible = false } }
List(transport.value.queue) { item in
HStack { Image(systemName:item.current ? "speaker.wave.2.fill" : "music.note").foregroundStyle(item.current ? .pink : .secondary);VStack(alignment:.leading) { Text(item.title);Text(item.artist).font(.caption).foregroundStyle(.secondary) } }
}
}.padding(22).frame(width:620,height:420).preferredColorScheme(.dark)
}
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ struct DJAudioDevicesPane: View {
}.onAppear {
system.refreshDevices();draft = system.routing
}
.onChange(of:draft) { next in
.onStudioChange(of:draft) { next in
if next != system.routing,validation == nil { system.applyRouting(next) }
}
.task {
+1 -1
View File
@@ -53,7 +53,7 @@ struct DJLoopWaveInteraction: ViewModifier {
.overlay {
DJWaveMouseSurface(player:player,start:start,span:span,width:width,relativeSeek:relativeSeek,frozenStart:$frozenStart)
}
.onChange(of:player.currentSongID) { _ in frozenStart = nil }
.onStudioChange(of:player.currentSongID) { _ in frozenStart = nil }
.onContinuousHover { phase in
switch phase {
case .active(let point):
+4 -4
View File
@@ -517,8 +517,8 @@ struct RotationDashboard: View {
}.padding(12).frame(minWidth:360).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10))
}
}.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor))
.onChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() }
.onChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } }
.onStudioChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() }
.onStudioChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } }
}
private func rosterRow(_ singer: PersonPlace,index: Int) -> some View {
HStack(spacing:7) {
@@ -666,8 +666,8 @@ struct StudioSettingsDialog: View {
Divider()
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : (scope == "DJ" ? "DJ Studio" : "Karaoke Studio")+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onChange(of:category) { _ in model.dj.midi.learning = nil }
.onStudioChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onStudioChange(of:category) { _ in model.dj.midi.learning = nil }
.onDisappear { model.dj.midi.learning = nil }
.alert("Settings",isPresented:Binding(get:{model.error != nil},set:{if !$0 { model.error = nil }})) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
}
+21 -39
View File
@@ -322,9 +322,9 @@ struct DJAudioDevice: Identifiable, Equatable {
let cache = waveformDirectory,locations = referenceLocations
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformTask = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force) { update in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force,progress: { update in
Task { @MainActor [weak self] in self?.waveformJob = update }
}
})
await MainActor.run { [weak self] in
guard let self else { return }
self.waveformJob = result;self.generatingWaveforms = false;self.waveformTask = nil
@@ -365,11 +365,6 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var error: String?
let root: URL
private(set) var library: Library?
private var appleMusicStorage: AnyObject?
@available(macOS 14.0, *) var appleMusic: AppleMusicSession {
if let session = appleMusicStorage as? AppleMusicSession { return session }
let session = AppleMusicSession();appleMusicStorage = session;return session
}
let dragSession = UUID().uuidString
@Published var songs = [Song]()
@Published var playlists = [PersonPlace]()
@@ -384,12 +379,6 @@ struct DJAudioDevice: Identifiable, Equatable {
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()
for deck in [a,b] {
deck.player.onWillStartPlayback = { [weak self] in
if #available(macOS 14.0, *),let session = self?.appleMusicStorage as? AppleMusicSession { session.pause() }
return 0
}
}
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
Task { @MainActor [weak self] in self?.maintainSync() }
}.store(in:&syncObservers)
@@ -910,8 +899,8 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
var list = MIDIPacketList()
withUnsafeMutablePointer(to:&list) { pointer in
let packet = MIDIPacketListInit(pointer)
let added = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,$0.count,$0.baseAddress!) }
if added != nil { MIDISend(outputPort,outputDestination,pointer) }
_ = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,$0.count,$0.baseAddress!) }
MIDISend(outputPort,outputDestination,pointer)
}
}
func remove(_ id: String) { mappings.removeAll{$0.id == id};save() }
@@ -1018,7 +1007,6 @@ struct DJConsole: View {
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var editingTrack: Song?
@State private var appleMusicVisible = false
@State private var graphicEQVisible = false
@State private var waveformDetails = false
@State private var favouritesOnly = false
@@ -1064,13 +1052,7 @@ struct DJConsole: View {
VStack(spacing:10) {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
if appleMusicVisible {
if #available(macOS 14.0, *) {
AppleMusicBrowser(session:system.appleMusic,hasPlayingDeck:{system.a.player.playing || system.b.player.playing},stopDecks:{system.automix.stop();if system.a.player.playing { system.a.player.toggle() };if system.b.player.playing { system.b.player.toggle() }})
} else { Text("Apple Music playback requires macOS 14 or later.").frame(maxWidth:.infinity,maxHeight:.infinity) }
} else {
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})
}
@@ -1134,21 +1116,20 @@ struct DJConsole: View {
.sheet(isPresented:$playlistEditor) { playlistSheet }
.alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
.onAppear { refreshPlaylist();connectMIDI() }
.onChange(of:selected) { _ in connectMIDI() }
.onChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id } }
.onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] }
.onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onChange(of:query) { _ in updateFilteredTracks();page = 0 }
.onChange(of:favouritesOnly) { _ in updateFilteredTracks();page = 0 }
.onChange(of:system.favourites) { _ in updateFilteredTracks() }
.onStudioChange(of:selected) { _ in connectMIDI() }
.onStudioChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id } }
.onStudioChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] }
.onStudioChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onStudioChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onStudioChange(of:query) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:favouritesOnly) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:system.favourites) { _ in updateFilteredTracks() }
}
private var header: some View {
HStack {
Image(systemName:"waveform.path").foregroundStyle(DJStyle.a)
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
Picker("Music source",selection:$appleMusicVisible) { Text("DJ Library").tag(false);Text("Apple Music").tag(true) }.pickerStyle(.segmented).frame(width:230)
Spacer()
Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.tint(system.graphicEQ.enabled ? .cyan : .accentColor)
DJQueueButton(automix:system.automix,open:{queueVisible = true})
@@ -1218,7 +1199,7 @@ struct DJConsole: View {
.scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:8))
.onCommand(#selector(NSResponder.selectAll(_:))) { selectAllTracks() }
.background(DJLibraryPageScroll(page:page) { if page < pages-1 { page += 1 } })
.onChange(of:page) { _ in
.onStudioChange(of:page) { _ in
selected = visible.first?.id;selection = Set(visible.first.map{[$0.id]} ?? [])
if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } }
}
@@ -2088,14 +2069,15 @@ enum DJFinderDrop {
static func urls(_ providers: [NSItemProvider]) async throws -> [URL] {
var urls = [URL]()
for provider in providers {
let data: Data? = await withCheckedContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in continuation.resume(returning:data) }
}
if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { urls.append(url);continue }
let url: URL = try await withCheckedThrowingContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in
if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url);return }
provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in
if let url = item as? URL,url.isFileURL { continuation.resume(returning:url) }
else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url) }
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read an absolute local file URL from Finder. Drop the audio file itself or import it with Import Files / Folder.")) }
}
provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in
if let url = item as? URL,url.isFileURL { continuation.resume(returning:url) }
else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url) }
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read an absolute local file URL from Finder. Drop the audio file itself or import it with Import Files / Folder.")) }
}
}
urls.append(url)
+3 -3
View File
@@ -204,10 +204,10 @@ struct PlaylistWorkspace: View {
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).sheet(isPresented:$adding) { addSheet }
.sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:model,ids:reinterpretIDs) }
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{ reinterpretIDs = nameSelection.isEmpty ? Set(rows.map(\.id)) : nameSelection.intersection(Set(rows.map(\.id)));reinterpreting = true },enabled:!rows.isEmpty))
.onChange(of:query) { _ in nameSelection = [] }
.onStudioChange(of:query) { _ in nameSelection = [] }
.confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } }
.onChange(of:sort) { _ in saveSort() }.onChange(of:ascending) { _ in saveSort() }
.onChange(of:selected) { id in
.onStudioChange(of:sort) { _ in saveSort() }.onStudioChange(of:ascending) { _ in saveSort() }
.onStudioChange(of:selected) { id in
nameSelection = []
if let id,let value = try? model.library?.setting("playlist.sort.\(id)") { let parts = value.components(separatedBy:"|");sort = parts[0];ascending = parts.count < 2 || parts[1] == "asc" } else { sort = "Saved Order";ascending = true }
}
+30 -2
View File
@@ -246,8 +246,8 @@ struct Importer {
graphics = target.path
}
imported += 1
let metadata = AVURLAsset(url:audio).commonMetadata
func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" }
let metadata = try audioMetadata(audio,cancelled:cancelled)
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)
songIDs.insert(id)
@@ -280,6 +280,27 @@ struct Importer {
while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) }
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] {
let result = ImportMetadataResult(),ready = DispatchSemaphore(value:0)
let task = Task.detached(priority:.utility) {
var tags = [AVMetadataKey:String]()
let items = (try? await AVURLAsset(url:url).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()
}
defer { task.cancel() }
while ready.wait(timeout:.now()+0.05) == .timedOut {
if cancelled() { throw CancellationError() }
}
if cancelled() { throw CancellationError() }
return result.get()
}
private func audioDuration(_ url: URL) -> Double {
guard let file = try? AVAudioFile(forReading:url) else { return 0 }
return Double(file.length)/file.processingFormat.sampleRate
@@ -669,3 +690,10 @@ enum DJLibraryMaintenance {
} catch { try? db.execute("ROLLBACK");throw error }
}
}
private final class ImportMetadataResult: @unchecked Sendable {
private let lock = NSLock()
private var tags = [AVMetadataKey:String]()
func set(_ value: [AVMetadataKey:String]) { lock.lock();tags = value;lock.unlock() }
func get() -> [AVMetadataKey:String] { lock.lock();defer { lock.unlock() };return tags }
}
+19 -7
View File
@@ -807,7 +807,7 @@ struct ImportLogView: View {
}.padding(12)
}.background(Color(nsColor:.textBackgroundColor),in:RoundedRectangle(cornerRadius:8))
.overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.primary.opacity(0.06)))
.onChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
.onStudioChange(of:lines.count) { _ in if follow,let last = lines.indices.last { reader.scrollTo(last,anchor:.bottom) } }
}
}
}
@@ -1041,9 +1041,9 @@ struct LibraryView: View {
.disabled(model.storageProgress != nil)
.overlay { if model.storageProgress != nil { ImportProgressDialog(model:model,storageMode:true) } }
.onAppear { updateLibraryRows() }
.onChange(of:model.songs) { _ in updateLibraryRows() }
.onChange(of:model.tuningCache) { _ in updateLibraryRows() }
.onChange(of:search) { _ in updateLibraryRows(resetPage:true) }
.onStudioChange(of:model.songs) { _ in updateLibraryRows() }
.onStudioChange(of:model.tuningCache) { _ in updateLibraryRows() }
.onStudioChange(of:search) { _ in updateLibraryRows(resetPage:true) }
.sheet(item: $model.creating) { kind in
AddEntitySheet(model: model, kind: kind) { id in
if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) }
@@ -1062,13 +1062,13 @@ struct LibraryView: View {
.confirmationDialog("Write file tags for \(tableSelection.count) songs? Original files will be backed up in TagBackups before replacement. Unsupported files are reported and left unchanged.",isPresented:$confirmWriteTags) {
Button("Write File Tags") { model.writeFileTags(tableSelection) }
}
.onChange(of:tableSelection) { ids in selected = ids.sorted().first }
.onStudioChange(of:tableSelection) { ids in selected = ids.sorted().first }
.sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) }
.sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer,tonight:songPickerTonight) }
.onChange(of:selected) { song in
.onStudioChange(of:selected) { song in
if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song }
}
.onChange(of:model.selectedSinger) { singer in
.onStudioChange(of:model.selectedSinger) { singer in
updateLibraryRows()
if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) }
}
@@ -2098,3 +2098,15 @@ private struct RepeatButton: View {
}
}
// Keep one change-observer API across the supported macOS versions.
extension View {
@ViewBuilder func onStudioChange<Value: Equatable>(of value: Value,perform action: @escaping (Value) -> Void) -> some View {
if #available(macOS 14.0, *) { onChange(of:value) { _,newValue in action(newValue) } }
else { legacyStudioChange(of:value,perform:action) }
}
@available(macOS,introduced:10.15,deprecated:14.0)
private func legacyStudioChange<Value: Equatable>(of value: Value,perform action: @escaping (Value) -> Void) -> some View {
onChange(of:value,perform:action)
}
}
+2 -2
View File
@@ -210,9 +210,9 @@ extension WaveformBatch {
let songs = pending.values.sorted{$0.id < $1.id},locations = self.locations,id = UUID()
pending.removeAll();token = id;latest = .init(total:songs.count);update?(latest!)
task = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:songs,locations:locations,cache:cache) { value in
let result = await WaveformBatch.runConcurrent(songs:songs,locations:locations,cache:cache,progress: { value in
Task { @MainActor [weak self] in guard let self,self.token == id,self.task != nil else { return };self.latest = value;self.update?(value) }
}
})
await MainActor.run { [weak self] in
guard let self,self.token == id else { return }
self.task = nil;self.latest = result;self.update?(result);self.completed?(songs)
+6 -2
View File
@@ -110,6 +110,10 @@ import CoreAudio
handoffLoopAudio(at:handoff)
return nativeLoopActive
}
private func replaceLoopAtBoundary(_ buffer: AVAudioPCMBuffer) {
// Scheduling must return immediately; awaiting a looping buffer never completes.
node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
}
private func prepareNativeLoop() {
loopBufferTask?.cancel()
guard loopEnabled,let start = loopIn,let end = loopOut,end > start,let url = audioURL else { return }
@@ -154,7 +158,7 @@ import CoreAudio
if let pending = self.pendingLoopSwitch,raw >= pending.at { activeRange = pending.range }
else { activeRange = self.scheduledLoop ?? start...end }
let remaining = max(0,activeRange.upperBound-self.loopPosition(raw))
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
self.replaceLoopAtBoundary(buffer)
self.pendingLoopSwitch = (raw+remaining,start...end,self.preparedRawLoop!)
} else if resume {
_ = self.activatePreparedLoop()
@@ -618,7 +622,7 @@ import CoreAudio
graphicsTask = Task.detached(priority:.userInitiated) { [weak self] in
var decoded = initial;decoded.render(data,at:seconds,cancelled:{Task.isCancelled})
guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded
await MainActor.run {
await MainActor.run { [weak self] in
guard let self,self.graphicsGeneration == token else { return }
self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil
}
+1 -1
View File
@@ -206,6 +206,6 @@ struct SingerServerPane: View {
}.padding(.vertical,4)
}
if let invitation { Text(invitation).font(.callout).foregroundStyle(.secondary) }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } }
}.padding(22).background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onStudioChange(of:server.running) { running in if running { address = server.localURL } }
}
}