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
+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 }}>