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:
@@ -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>
|
||||
|
||||
@@ -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'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.')}
|
||||
|
||||
Reference in New Issue
Block a user