Files
chrisandClaude Opus 5.5 1d4aeee0dd Connect Apple Music and Spotify-via-Last.fm on iPhone
- 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
2026-10-04 07:12:19 -04:00

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"
}
}
}