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
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["AppleMusicModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// iOS-only native module; the default export is null where it is unavailable.
|
||||
export { default } from './src/AppleMusicModule';
|
||||
export * from './src/AppleMusic.types';
|
||||
@@ -0,0 +1,24 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'AppleMusic'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Apple Music authorization, user token and Music app now-playing'
|
||||
s.description = 'Local Expo module for musicshare'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = {
|
||||
:ios => '16.4',
|
||||
:tvos => '16.4'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.frameworks = 'MusicKit', 'MediaPlayer'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type AppleMusicAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unknown';
|
||||
|
||||
export type MusicAppPlayback =
|
||||
| { state: 'stopped' }
|
||||
| {
|
||||
state: 'playing' | 'paused' | 'stopped';
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationMs: number;
|
||||
storeId: string;
|
||||
};
|
||||
|
||||
export type AppleMusicModuleEvents = {
|
||||
onNowPlayingChange: (playback: MusicAppPlayback) => void;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||
|
||||
import type { AppleMusicAuthorization, AppleMusicModuleEvents, MusicAppPlayback } from './AppleMusic.types';
|
||||
|
||||
declare class AppleMusicModule extends NativeModule<AppleMusicModuleEvents> {
|
||||
authorizationStatus(): AppleMusicAuthorization;
|
||||
requestAuthorization(): Promise<AppleMusicAuthorization>;
|
||||
getUserToken(developerToken: string): Promise<string>;
|
||||
getNowPlaying(): Promise<MusicAppPlayback>;
|
||||
}
|
||||
|
||||
/** iOS only. null on Android, web and Expo Go, so callers must feature-check. */
|
||||
export default requireOptionalNativeModule<AppleMusicModule>('AppleMusic');
|
||||
Reference in New Issue
Block a user