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,80 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import AppleMusic, { type MusicAppPlayback } from '@/modules/apple-music';
|
||||
import { api } from './api';
|
||||
import { reportPlayback } from './device';
|
||||
|
||||
/** True on iOS builds that include the native module (not Android, web, or Expo Go). */
|
||||
export const appleMusicAvailable = AppleMusic !== null;
|
||||
|
||||
export class AppleMusicPermissionError extends Error {}
|
||||
|
||||
/**
|
||||
* The whole Apple Music setup: one system prompt. Asks for access, exchanges
|
||||
* our developer token for the user's Music User Token, and hands it to the
|
||||
* server, which then follows recently played in the background.
|
||||
*/
|
||||
export async function connectAppleMusic() {
|
||||
if (!AppleMusic) throw new Error('Apple Music is only available in the iPhone app');
|
||||
let status = AppleMusic.authorizationStatus();
|
||||
if (status === 'notDetermined') status = await AppleMusic.requestAuthorization();
|
||||
if (status !== 'authorized') {
|
||||
throw new AppleMusicPermissionError(
|
||||
status === 'restricted'
|
||||
? 'Apple Music access is restricted on this iPhone (Screen Time or a device profile).'
|
||||
: 'Turn on Media & Apple Music for musicshare in the Settings app, then try again.',
|
||||
);
|
||||
}
|
||||
const { token } = await api.appleMusicDeveloperToken();
|
||||
const musicUserToken = await AppleMusic.getUserToken(token);
|
||||
return api.connectAppleMusic(musicUserToken);
|
||||
}
|
||||
|
||||
const HEARTBEAT_MS = 4 * 60_000; // the server expires silent phones after 9 minutes
|
||||
let lastSent: { key: string; at: number } | null = null;
|
||||
|
||||
function report(playback: MusicAppPlayback) {
|
||||
const key = 'title' in playback ? `${playback.state}|${playback.title}|${playback.artist}` : 'stopped';
|
||||
const now = Date.now();
|
||||
if (lastSent?.key === key && (playback.state !== 'playing' || now - lastSent.at < HEARTBEAT_MS)) return;
|
||||
if (!lastSent && key === 'stopped') return; // nothing was shared yet; no need to say "stopped"
|
||||
lastSent = { key, at: now };
|
||||
reportPlayback(
|
||||
'title' in playback && playback.state !== 'stopped'
|
||||
? {
|
||||
app: 'com.apple.Music',
|
||||
appName: 'Apple Music',
|
||||
state: playback.state,
|
||||
track: {
|
||||
title: playback.title,
|
||||
artist: playback.artist,
|
||||
album: playback.album || null,
|
||||
durationMs: playback.durationMs > 0 ? Math.round(playback.durationMs) : null,
|
||||
},
|
||||
}
|
||||
: { app: 'com.apple.Music', appName: 'Apple Music', state: 'stopped' },
|
||||
).catch(() => {
|
||||
lastSent = null; // retry on the next change
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* While the app is open, mirror the Music app exactly. iOS doesn't let us watch
|
||||
* it in the background; then the server's recently-played polling takes over.
|
||||
*/
|
||||
export function useAppleMusicLive(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !AppleMusic || AppleMusic.authorizationStatus() !== 'authorized') return;
|
||||
const music = AppleMusic;
|
||||
const check = () => music.getNowPlaying().then(report).catch(() => {});
|
||||
check();
|
||||
const changes = music.addListener('onNowPlayingChange', report);
|
||||
const foreground = AppState.addEventListener('change', (s) => s === 'active' && check());
|
||||
const heartbeat = setInterval(check, HEARTBEAT_MS);
|
||||
return () => {
|
||||
changes.remove();
|
||||
foreground.remove();
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user