Files
KMix_Pro/KJamsClone/AppleMusic.swift
T

296 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}