- 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
81 lines
3.3 KiB
TypeScript
81 lines
3.3 KiB
TypeScript
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]);
|
|
}
|