removed unwanted bar now with waveforms, major updates and enhancements
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ?? "") }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user