Files
musicshare/apps/mobile/src/lib/appleMusic.ts
T
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

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]);
}