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
+12 -2
View File
@@ -3,13 +3,15 @@ import { useEffect, useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader';
import { appleMusicAvailable } from '@/lib/appleMusic';
import { useReader } from '@/lib/nowPlayingReader';
import { shouldOfferSetup } from '@/lib/setup';
import { useRealtime } from '@/lib/useRealtime';
import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
@@ -22,6 +24,8 @@ export default function FeedScreen() {
const [refreshing, setRefreshing] = useState(false);
const reader = useReader();
const { data: connections } = useSWR<ConnectionDTO[]>(appleMusicAvailable ? '/connections' : null);
const offerAppleMusic = appleMusicAvailable && connections !== undefined && connections.length === 0;
// First run on Android: open the one-tap setup once (deferred so navigation has mounted).
useEffect(() => {
@@ -74,6 +78,12 @@ export default function FeedScreen() {
ListHeaderComponent={
<View style={styles.header}>
<SharingControls compact />
{offerAppleMusic && (
<Card>
<Body>Connect Apple Music so friends see what you play. One tap.</Body>
<Button small variant="primary" title="Connect" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
</Card>
)}
{reader.available && !reader.granted && (
<Card>
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
+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.')}
+11 -1
View File
@@ -3,9 +3,11 @@ import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import { AppState, StyleSheet, View } from 'react-native';
import { SWRConfig } from 'swr';
import useSWR, { SWRConfig } from 'swr';
import type { ConnectionDTO } from '@musicshare/shared';
import { Body, Button, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader';
import { SessionProvider, useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
@@ -65,6 +67,7 @@ function AppNavigator() {
return () => sub.remove();
},
}}>
{signedIn && appleMusicAvailable && <AppleMusicLive />}
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
@@ -86,6 +89,13 @@ function AppNavigator() {
);
}
/** iOS: mirror the Music app live while the app is open, once Apple Music is connected. */
function AppleMusicLive() {
const { data: connections } = useSWR<ConnectionDTO[]>('/connections');
useAppleMusicLive(Boolean(connections?.some((c) => c.provider === 'apple_music' && c.status === 'active')));
return null;
}
const styles = StyleSheet.create({
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
});
+69 -12
View File
@@ -1,24 +1,83 @@
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { Platform, StyleSheet, View } from 'react-native';
import { mutate } from 'swr';
import { Screen } from '@/components/Screen';
import { Body, Button, Card, Title } from '@/components/ui';
import { showError } from '@/lib/errors';
import { markSetupSeen, openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
import { markSetupSeen } from '@/lib/setup';
import { colors, space } from '@/lib/theme';
/**
* One-time onboarding on Android. This doubles as the prominent disclosure Google
* Play requires before sending users to the notification-access screen.
*/
export default function ShareSetupScreen() {
const { granted, playback } = useReader();
const [synced, setSynced] = useState(false);
const finishing = granted && !synced;
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
export default function ShareSetupScreen() {
useEffect(() => {
markSetupSeen();
}, []);
return Platform.OS === 'ios' ? <AppleMusicSetup /> : <AndroidSetup />;
}
/** iPhone: Apple Music is one system prompt. Other services live in Settings. */
function AppleMusicSetup() {
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
setConnecting(true);
setError(null);
try {
await connectAppleMusic();
await mutate('/connections');
setConnected(true);
} catch (err) {
setError(err instanceof AppleMusicPermissionError ? err.message : "Couldn't connect Apple Music. Try again.");
}
setConnecting(false);
};
return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={{ gap: space.md }}>
<Title>{connected ? "You're all set" : 'Share what you’re playing'}</Title>
<Body muted>
{connected
? 'Play something in Apple Music and your friends will see it.'
: 'Connect Apple Music and your friends see what you play. It takes one tap.'}
</Body>
</View>
{!connected && (
<Card>
<Body style={styles.point}>• We see the songs you play in Apple Music, and nothing else.</Body>
<Body style={styles.point}>• Use Spotify or Plex? Connect them in Settings.</Body>
<Body style={styles.point}>• Pause sharing or disconnect any time.</Body>
</Card>
)}
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
<View style={{ gap: space.sm }}>
{connected ? (
<Button variant="primary" title="Done" onPress={close} />
) : (
<>
<Button variant="primary" title="Connect Apple Music" loading={connecting} onPress={connect} />
<Button title="Not now" onPress={close} />
</>
)}
</View>
</Screen>
);
}
/**
* Android: one toggle covers every music app. This screen doubles as the
* prominent disclosure Google Play requires before the notification-access screen.
*/
function AndroidSetup() {
const { granted, playback } = useReader();
const [synced, setSynced] = useState(false);
const finishing = granted && !synced;
useEffect(() => {
if (!granted) return;
@@ -27,8 +86,6 @@ export default function ShareSetupScreen() {
.finally(() => setSynced(true));
}, [granted]);
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={{ gap: space.md }}>