296 lines
19 KiB
Swift
296 lines
19 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|