diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index e74efa2..e5b6c2e 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -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 = ""; }; B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = ""; }; B60000000000000000000002 /* DJNativeDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJNativeDragDrop.swift; sourceTree = ""; }; - B70000000000000000000002 /* AppleMusic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusic.swift; sourceTree = ""; }; /* 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; diff --git a/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme b/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme index 9a707eb..04fd964 100644 --- a/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme +++ b/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme @@ -1,7 +1,78 @@ - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KJamsClone/AppleMusic.swift b/KJamsClone/AppleMusic.swift deleted file mode 100644 index 9a25dd8..0000000 --- a/KJamsClone/AppleMusic.swift +++ /dev/null @@ -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();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();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? - private var playbackTask: Task? - 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) - } - } -} diff --git a/KJamsClone/DJAudioRouting.swift b/KJamsClone/DJAudioRouting.swift index 062bb65..7c0a910 100644 --- a/KJamsClone/DJAudioRouting.swift +++ b/KJamsClone/DJAudioRouting.swift @@ -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 { diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index 921e323..713a634 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -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): diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index 92c62dd..f447f69 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -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 ?? "") } } diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 64802b3..6317006 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -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.size,packet,0,$0.count,$0.baseAddress!) } - if added != nil { MIDISend(outputPort,outputDestination,pointer) } + _ = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout.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() @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) diff --git a/KJamsClone/Hosting.swift b/KJamsClone/Hosting.swift index 121a6cb..a001ae6 100644 --- a/KJamsClone/Hosting.swift +++ b/KJamsClone/Hosting.swift @@ -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 } } diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift index 7f5319a..97be71a 100644 --- a/KJamsClone/Importer.swift +++ b/KJamsClone/Importer.swift @@ -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 } +} diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 0594fbb..cd57f96 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -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(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(of value: Value,perform action: @escaping (Value) -> Void) -> some View { + onChange(of:value,perform:action) + } +} diff --git a/KJamsClone/LibraryScanner.swift b/KJamsClone/LibraryScanner.swift index 79fe9fd..f3040f8 100644 --- a/KJamsClone/LibraryScanner.swift +++ b/KJamsClone/LibraryScanner.swift @@ -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) diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 03c2a05..05169d8 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -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 } diff --git a/KJamsClone/SingerServer.swift b/KJamsClone/SingerServer.swift index 74f82e2..d1cdb34 100644 --- a/KJamsClone/SingerServer.swift +++ b/KJamsClone/SingerServer.swift @@ -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 } } } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 2657b66..d47bffc 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -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 { } -} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 6036ac6..2d26368 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -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).