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:
2026-10-04 07:12:19 -04:00
co-authored by Claude Opus 5.5
parent ca97f1c3ac
commit 1d4aeee0dd
16 changed files with 522 additions and 78 deletions
+94 -19
View File
@@ -18,6 +18,7 @@ import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { useDebounced } from '@/lib/useDebounced';
import { useSession } from '@/lib/session';
@@ -127,18 +128,54 @@ function Connections() {
await mutate();
};
const connectOAuth = (provider: 'spotify' | 'plex') =>
/** Sign in on the service's site and come back via deep link. Returns true when connected. */
const signIn = async (provider: 'spotify' | 'plex' | 'lastfm'): Promise<boolean> => {
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
const returnTo = Linking.createURL('settings');
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
if (result.type !== 'success') return false;
const error = Linking.parse(result.url).queryParams?.error;
if (error === 'spotify_limited') {
offerSpotifyViaLastfm();
return false;
}
if (typeof error === 'string') {
Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
return false;
}
return true;
};
const connectOAuth = (provider: 'spotify' | 'plex' | 'lastfm') => run(() => signIn(provider));
/**
* Spotify only lets small apps sign in a handful of accounts. Spotify's own
* Last.fm link works for everyone: sign in to Last.fm here, then approve
* "Spotify Scrobbling" on Last.fm.
*/
const linkSpotifyOnLastfm = () =>
WebBrowser.openBrowserAsync('https://www.last.fm/settings/applications').catch((e) => showError(e));
const setUpSpotifyViaLastfm = () =>
run(async () => {
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
const returnTo = Linking.createURL('settings');
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
if (result.type === 'success') {
const error = Linking.parse(result.url).queryParams?.error;
if (typeof error === 'string') Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
}
if (!byProvider.has('lastfm') && !(await signIn('lastfm'))) return;
Alert.alert('Last step: link Spotify', 'On the next page, tap Connect next to "Spotify Scrobbling" and approve.', [
{ text: 'Open Last.fm', onPress: linkSpotifyOnLastfm },
]);
});
function offerSpotifyViaLastfm() {
Alert.alert(
'Spotify sign-in is limited during beta',
"Spotify only allows a few accounts to sign in to new apps directly. You can still share Spotify through Last.fm: two quick approvals, and it works for everyone.",
[
{ text: 'Not now', style: 'cancel' },
{ text: 'Set up', onPress: setUpSpotifyViaLastfm },
],
);
}
const disconnect = (provider: NowPlayingProvider) =>
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
{ text: 'Cancel', style: 'cancel' },
@@ -159,6 +196,16 @@ function Connections() {
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
const linkAppleMusic = () =>
run(async () => {
try {
await connectAppleMusic();
} catch (err) {
if (err instanceof AppleMusicPermissionError) Alert.alert('Apple Music access needed', err.message);
else throw err;
}
});
return (
<Card>
<Heading>Music services</Heading>
@@ -166,6 +213,20 @@ function Connections() {
Connect where you listen. Friends see what you&apos;re playing in real time.
</Body>
{appleMusicAvailable && (
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Apple Music</Text>
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
</View>
{isActive('apple_music') ? (
<Button small title="Disconnect" onPress={() => disconnect('apple_music')} />
) : (
<Button small variant="primary" title={byProvider.has('apple_music') ? 'Reconnect' : 'Connect'} onPress={linkAppleMusic} />
)}
</View>
)}
{(['spotify', 'plex'] as const).map((p) => (
<View key={p} style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
@@ -180,16 +241,30 @@ function Connections() {
</View>
))}
{(['lastfm', 'listenbrainz'] as const).map((p) => (
<UsernameConnection
key={p}
provider={p}
status={status(p, p === 'lastfm' ? 'Anything that scrobbles: YouTube Music, Tidal, Deezer…' : 'Open-source scrobbling')}
active={byProvider.has(p)}
onConnect={(username) => run(() => api.connectUsername(p, username))}
onDisconnect={() => disconnect(p)}
/>
))}
<View style={styles.connectionColumn}>
<View style={styles.connection}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Last.fm</Text>
{status('lastfm', 'Also the way to share Spotify for everyone (via Spotify’s Last.fm link).')}
</View>
{byProvider.has('lastfm') ? (
<Button small title="Disconnect" onPress={() => disconnect('lastfm')} />
) : (
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
)}
</View>
{byProvider.has('lastfm') && (
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
)}
</View>
<UsernameConnection
provider="listenbrainz"
status={status('listenbrainz', 'Open-source scrobbling')}
active={byProvider.has('listenbrainz')}
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
onDisconnect={() => disconnect('listenbrainz')}
/>
<JellyfinConnection
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}