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
+74 -11
View File
@@ -27,7 +27,6 @@
B40000000000000000000001 /* DJAutomix.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40000000000000000000002 /* DJAutomix.swift */; };
B50000000000000000000001 /* DJLoopEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50000000000000000000002 /* DJLoopEditing.swift */; };
B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60000000000000000000002 /* DJNativeDragDrop.swift */; };
B70000000000000000000001 /* AppleMusic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000002 /* AppleMusic.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -63,7 +62,6 @@
B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = "<group>"; };
B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = "<group>"; };
B60000000000000000000002 /* DJNativeDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJNativeDragDrop.swift; sourceTree = "<group>"; };
B70000000000000000000002 /* AppleMusic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusic.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -87,7 +85,6 @@
B40000000000000000000002 /* DJAutomix.swift */,
B50000000000000000000002 /* DJLoopEditing.swift */,
B60000000000000000000002 /* DJNativeDragDrop.swift */,
B70000000000000000000002 /* AppleMusic.swift */,
B20000000000000000000004 /* MIDIMappings */,
000000000000000000000002 /* Library.swift */,
000000000000000000000003 /* CDG.swift */,
@@ -162,7 +159,8 @@
F00000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1600;
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2720;
};
buildConfigurationList = E00000000000000000000001 /* Build configuration list for PBXProject "KJMix Pro" */;
compatibilityVersion = "Xcode 14.0";
@@ -215,7 +213,6 @@
B40000000000000000000001 /* DJAutomix.swift in Sources */,
B50000000000000000000001 /* DJLoopEditing.swift in Sources */,
B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */,
B70000000000000000000001 /* AppleMusic.swift in Sources */,
000000000000000000000066 /* Library.swift in Sources */,
000000000000000000000067 /* CDG.swift in Sources */,
000000000000000000000068 /* Importer.swift in Sources */,
@@ -251,9 +248,39 @@
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
@@ -263,8 +290,38 @@
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
@@ -276,8 +333,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
ENABLE_TESTABILITY = YES;
@@ -285,9 +344,8 @@
INFOPLIST_KEY_CFBundleDisplayName = "KJMix Pro";
INFOPLIST_KEY_CFBundleName = "KJMix Pro";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSAppleMusicUsageDescription = "KJMix Pro uses Apple Music to browse your library and playlists, search the catalog and play the music you choose.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server.";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 0.6.9;
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.kmixpro.app;
@@ -305,17 +363,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "KJMix Pro";
INFOPLIST_KEY_CFBundleName = "KJMix Pro";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSAppleMusicUsageDescription = "KJMix Pro uses Apple Music to browse your library and playlists, search the catalog and play the music you choose.";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server.";
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 0.6.9;
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.kmixpro.app;
@@ -332,9 +391,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.tests;
PRODUCT_NAME = KJMixProTests;
@@ -350,8 +411,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.tests;
PRODUCT_NAME = KJMixProTests;
@@ -1,7 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.3">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D00000000000000000000001" BuildableName="KJMix Pro.app" BlueprintName="KJMix Pro" ReferencedContainer="container:KJMix Pro.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"><Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D00000000000000000000002" BuildableName="KJMixProTests.xctest" BlueprintName="KJMixProTests" ReferencedContainer="container:KJMix Pro.xcodeproj"/></TestableReference></Testables></TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"><BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D00000000000000000000001" BuildableName="KJMix Pro.app" BlueprintName="KJMix Pro" ReferencedContainer="container:KJMix Pro.xcodeproj"/></BuildableProductRunnable></LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"/><AnalyzeAction buildConfiguration="Debug"/><ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
</Scheme>
<Scheme
LastUpgradeVersion = "2720"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D00000000000000000000001"
BuildableName = "KJMix Pro.app"
BlueprintName = "KJMix Pro"
ReferencedContainer = "container:KJMix Pro.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D00000000000000000000002"
BuildableName = "KJMixProTests.xctest"
BlueprintName = "KJMixProTests"
ReferencedContainer = "container:KJMix Pro.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D00000000000000000000001"
BuildableName = "KJMix Pro.app"
BlueprintName = "KJMix Pro"
ReferencedContainer = "container:KJMix Pro.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
-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 } }
}
}
-59
View File
@@ -3187,64 +3187,5 @@ extension KJamsCloneTests {
XCTAssertLessThan(player.audioContinuity.largestStep,0.08,"Loop handoffs must not introduce a hard sample discontinuity")
}
@MainActor func testAppleMusicAuthorizationAndSubscriptionGuardPlayback() async throws {
guard #available(macOS 14.0, *) else { throw XCTSkip("MusicKit playback requires macOS 14") }
let client = TestAppleMusicClient(),session = AppleMusicSession(client:client)
XCTAssertEqual(client.authorizationRequests,0)
client.granted = .denied;await session.connect()
XCTAssertEqual(session.access,.denied);XCTAssertNotNil(session.error);XCTAssertEqual(client.browseRequests,0)
client.granted = .authorized;client.subscription = false;await session.connect()
for _ in 0..<100 where session.busy { try await Task.sleep(nanoseconds:1_000_000) }
let track = try XCTUnwrap(session.tracks.first)
await session.play(track);XCTAssertTrue(client.played.isEmpty);XCTAssertNotNil(session.error)
client.subscription = true;await session.connect()
for _ in 0..<100 where session.busy { try await Task.sleep(nanoseconds:1_000_000) }
await session.play(track);XCTAssertEqual(client.played,[track.id]);XCTAssertTrue(session.playback.playing)
session.seek(1000);XCTAssertEqual(client.playback.position,track.duration)
session.stop();XCTAssertFalse(session.playback.playing)
}
@MainActor func testAppleMusicSearchRejectsStaleResultsAndPaginatesWithoutDuplicates() async throws {
guard #available(macOS 14.0, *) else { throw XCTSkip("MusicKit playback requires macOS 14") }
let client = TestAppleMusicClient(),session = AppleMusicSession(client:client)
session.query = "slow";session.reload();try await Task.sleep(nanoseconds:5_000_000)
session.query = "latest";session.reload();try await Task.sleep(nanoseconds:150_000_000)
XCTAssertEqual(session.tracks.map(\.id),["latest"])
session.more()
for _ in 0..<100 where session.loadingMore { try await Task.sleep(nanoseconds:1_000_000) }
XCTAssertEqual(session.tracks.map(\.id),["latest","next"]);XCTAssertFalse(session.hasMore);XCTAssertTrue(client.played.isEmpty)
let view = NSHostingView(rootView:AppleMusicBrowser(session:session,hasPlayingDeck:{false},stopDecks:{}).frame(width:1100,height:420))
view.frame = NSRect(x:0,y:0,width:1100,height:420);view.layoutSubtreeIfNeeded()
let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds));view.cacheDisplay(in:view.bounds,to:bitmap)
let png = try XCTUnwrap(bitmap.representation(using:.png,properties:[:]))
XCTContext.runActivity(named:"Apple Music browser layout") { activity in
let attachment = XCTAttachment(data:png,uniformTypeIdentifier:"public.png");attachment.lifetime = .keepAlways;activity.add(attachment)
}
}
}
@available(macOS 14.0, *) @MainActor private final class TestAppleMusicClient: AppleMusicClient {
var access: AppleMusicAccess = .authorized
var granted: AppleMusicAccess = .authorized
var subscription = true
var authorizationRequests = 0,browseRequests = 0
var played = [String]()
var playback = AppleMusicPlayback()
func authorize() async -> AppleMusicAccess { authorizationRequests += 1;access = granted;return granted }
func subscribed() async throws -> Bool { subscription }
func playlists() async throws -> [AppleMusicPlaylist] { [.init(id:"playlist",name:"My playlist")] }
func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage {
browseRequests += 1
if query == "slow" { try? await Task.sleep(nanoseconds:100_000_000) }
let id = query.isEmpty ? "library" : query
let track = AppleMusicTrack(id:id,title:id,artist:"Artist",album:"Album",duration:180,artwork:nil)
return .init(tracks:offset == 0 ? [track] : [track,.init(id:"next",title:"Next",artist:"Artist",album:"Album",duration:180,artwork:nil)],hasMore:offset == 0)
}
func play(_ id: String) async throws { played.append(id);playback = .init(title:id,artist:"Artist",playing:true,position:0,duration:180) }
func enqueue(_ id: String) async throws { }
func resume() async throws { playback.playing = true }
func pause() { playback.playing = false }
func stop() { playback.playing = false;playback.position = 0 }
func seek(_ seconds: Double) { playback.position = seconds }
func next() async throws { }
}
-4
View File
@@ -132,7 +132,3 @@ Both library toolbars share coloured, rounded action buttons and matching search
Loop activation and cancellation prepare an idle transport node while the audible node keeps running. Both feed the same effects chain. A shared host-clock start (up to 50 ms lookahead) and per-sample 12 ms complementary envelopes overlap the streams before retiring the outgoing node. The first partial loop pass uses the same processed seam as subsequent passes. Regression checks measure silent runs and adjacent-sample jumps across repeated start/stop operations, including two playing decks and changed tempos, as well as actual loop content and natural end-of-track completion.
Apple Music is available in DJ Studio’s music-source selector on macOS 14 or later. Connect uses native MusicKit authorisation; browse My Library and playlists, search the catalog, load further results, play, pause, stop, seek, skip and enqueue. Starting Apple Music while decks are playing asks before stopping deck playback and Automix. Nothing streams automatically at launch. MusicKit handles user tokens; no private keys or subscriber credentials are stored by KJMix Pro.
Enable the MusicKit App Service for the existing app identifier `com.kjamsclone.app` in the Apple Developer account, and run a signed build. The project includes `NSAppleMusicUsageDescription`; a subscription and network access are required for catalog playback. The unsigned verification build cannot validate developer-token generation or subscriber streaming. Public MusicKit uses Apple’s single application player and system audio output, not the local two-deck DSP graph: EQ/filter, crossfader, cue routing, sync, loops, waveform generation and recording of protected streamed audio are not provided. Apple-approved DJ SDK integration would require its separate documentation/access. See [Apple MusicKit](https://developer.apple.com/musickit/) and [ApplicationMusicPlayer](https://developer.apple.com/documentation/musickit/applicationmusicplayer).