- Local Expo module (Swift): MusicKit authorization, Music User Token via MusicUserTokenProvider, and live Music app now-playing while the app is open (MediaPlayer notifications), reported through the device endpoint - One-tap Apple Music onboarding on iOS; Apple Music row in Settings - Sign in with Last.fm, plus a guided Spotify-via-Last.fm flow when direct Spotify sign-in is capped - Device registration moved to shared code used by Android and iOS Not yet compiled for iOS (no Xcode here); needs an EAS build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
97 lines
3.2 KiB
Swift
97 lines
3.2 KiB
Swift
import ExpoModulesCore
|
|
import MediaPlayer
|
|
import MusicKit
|
|
|
|
/**
|
|
* Apple Music for the iOS app:
|
|
* - the one-tap "Allow access to Apple Music" prompt (MusicKit authorization)
|
|
* - the Music User Token the server needs to read recently played
|
|
* - live now-playing from the Music app while our app is open
|
|
* (iOS doesn't let apps observe the Music app in the background)
|
|
*/
|
|
public class AppleMusicModule: Module {
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
public func definition() -> ModuleDefinition {
|
|
Name("AppleMusic")
|
|
|
|
Events("onNowPlayingChange")
|
|
|
|
Function("authorizationStatus") { () -> String in
|
|
Self.describe(MusicAuthorization.currentStatus)
|
|
}
|
|
|
|
AsyncFunction("requestAuthorization") { () async -> String in
|
|
Self.describe(await MusicAuthorization.request())
|
|
}
|
|
|
|
/// developerToken comes from our server (signed with the MusicKit key).
|
|
AsyncFunction("getUserToken") { (developerToken: String) async throws -> String in
|
|
try await MusicUserTokenProvider().userToken(for: developerToken, options: .ignoreCache)
|
|
}
|
|
|
|
AsyncFunction("getNowPlaying") { () async -> [String: Any] in
|
|
await MainActor.run { Self.snapshot() }
|
|
}
|
|
|
|
OnStartObserving {
|
|
DispatchQueue.main.async { self.startObserving() }
|
|
}
|
|
|
|
OnStopObserving {
|
|
DispatchQueue.main.async { self.stopObserving() }
|
|
}
|
|
}
|
|
|
|
private func startObserving() {
|
|
guard observers.isEmpty, MusicAuthorization.currentStatus == .authorized else { return }
|
|
let player = MPMusicPlayerController.systemMusicPlayer
|
|
player.beginGeneratingPlaybackNotifications()
|
|
let center = NotificationCenter.default
|
|
for name in [Notification.Name.MPMusicPlayerControllerNowPlayingItemDidChange,
|
|
Notification.Name.MPMusicPlayerControllerPlaybackStateDidChange] {
|
|
observers.append(center.addObserver(forName: name, object: player, queue: .main) { [weak self] _ in
|
|
self?.sendEvent("onNowPlayingChange", Self.snapshot())
|
|
})
|
|
}
|
|
}
|
|
|
|
private func stopObserving() {
|
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
observers.removeAll()
|
|
MPMusicPlayerController.systemMusicPlayer.endGeneratingPlaybackNotifications()
|
|
}
|
|
|
|
/// Must be called on the main thread.
|
|
private static func snapshot() -> [String: Any] {
|
|
let player = MPMusicPlayerController.systemMusicPlayer
|
|
guard let item = player.nowPlayingItem, let title = item.title, !title.isEmpty else {
|
|
return ["state": "stopped"]
|
|
}
|
|
let state: String
|
|
switch player.playbackState {
|
|
case .playing, .seekingForward, .seekingBackward: state = "playing"
|
|
case .paused, .interrupted: state = "paused"
|
|
default: state = "stopped"
|
|
}
|
|
return [
|
|
"state": state,
|
|
"title": title,
|
|
"artist": item.artist ?? "",
|
|
"album": item.albumTitle ?? "",
|
|
"durationMs": item.playbackDuration * 1000,
|
|
"storeId": item.playbackStoreID,
|
|
]
|
|
}
|
|
|
|
private static func describe(_ status: MusicAuthorization.Status) -> String {
|
|
switch status {
|
|
case .authorized: return "authorized"
|
|
case .denied: return "denied"
|
|
case .restricted: return "restricted"
|
|
case .notDetermined: return "notDetermined"
|
|
@unknown default: return "unknown"
|
|
}
|
|
}
|
|
}
|