Mobile: redesign, onboarding, QR codes, logo, Spotify setup

- Redesign matching the web: album-art backdrops, Reanimated equalizer,
  spinning live ring, drifting ambient background, gradient buttons with
  spring press feedback and haptics, Space Grotesk headings, animated feed
  (enter + layout transitions); fix invisible tab labels
- Four-step onboarding for new accounts: which app you listen with (sets
  where songs open), the matching share setup, find friends (search,
  scan, invite), and a featured song. Skippable; stored server-side
- QR codes: My code (share link) and an in-app scanner (camera only, no
  microphone permission)
- Spotify setup via Last.fm or ListenBrainz with guided linking; Android
  shares Spotify automatically through the phone reader
- New logo as app icon, adaptive/monochrome icon and splash
- app.config.ts allows cleartext only for local http:// test builds

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-05 19:16:40 -04:00
co-authored by Claude Opus 5.5
parent 17ae36064c
commit 3568b71e90
44 changed files with 1481 additions and 358 deletions
+16 -9
View File
@@ -1,9 +1,11 @@
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { RefreshControl, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground';
import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder';
import { SharingControls } from '@/components/SharingControls';
@@ -71,7 +73,10 @@ export default function FeedScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<FlatList
<AmbientBackground />
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
<Animated.FlatList
itemLayoutAnimation={LinearTransition.springify().damping(18)}
data={feed ?? []}
keyExtractor={(item) => item.user.id}
contentContainerStyle={styles.list}
@@ -115,13 +120,15 @@ export default function FeedScreen() {
</Card>
)
}
renderItem={({ item }) => (
<NowPlayingCard
np={item}
preferred={me?.preferredService}
pinned={Boolean(item.pinnedAt)}
onTogglePin={() => togglePin(item)}
/>
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
<NowPlayingCard
np={item}
preferred={me?.preferredService}
pinned={Boolean(item.pinnedAt)}
onTogglePin={() => togglePin(item)}
/>
</Animated.View>
)}
/>
</SafeAreaView>
+7 -1
View File
@@ -4,8 +4,9 @@ import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import type { UserSummaryDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground';
import { Avatar } from '@/components/Avatar';
import { Body, Field, Title } from '@/components/ui';
import { Body, Button, Field, Row, Title } from '@/components/ui';
import { useDebounced } from '@/lib/useDebounced';
import { colors, space } from '@/lib/theme';
@@ -16,6 +17,7 @@ export default function SearchScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<AmbientBackground />
<FlatList
data={query.length >= 2 ? (data ?? []) : []}
keyExtractor={(u) => u.id}
@@ -24,6 +26,10 @@ export default function SearchScreen() {
ListHeaderComponent={
<View style={{ gap: space.md }}>
<Title>Find friends</Title>
<Row>
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
<Button small title="My code" onPress={() => router.push('/qr')} />
</Row>
<Field
placeholder="Search by name or username"
value={q}
+40 -139
View File
@@ -1,9 +1,6 @@
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { Alert, Platform, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import {
SERVICE_LABELS,
@@ -11,17 +8,17 @@ import {
timeAgo,
type ConnectionDTO,
type NowPlayingProvider,
type TrackSearchResultDTO,
} from '@musicshare/shared';
import { RelinkReminder } from '@/components/RelinkReminder';
import { FeaturedPicker } from '@/components/FeaturedPicker';
import { Screen } from '@/components/Screen';
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 { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { useDebounced } from '@/lib/useDebounced';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
@@ -130,53 +127,19 @@ function Connections() {
await mutate();
};
/** 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 = () =>
/** Sign in on the service's site (Plex, Last.fm) and come back to the app. */
const connectOAuth = (provider: 'plex' | 'lastfm') =>
run(async () => {
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 },
]);
try {
await signInWithService(provider);
} catch (err) {
if (err instanceof SignInError) Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, err.message);
else throw err;
}
});
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 linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
const disconnect = (provider: NowPlayingProvider) =>
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
@@ -229,25 +192,39 @@ function Connections() {
</View>
)}
{(['spotify', 'plex'] as const).map((p) => (
<View key={p} style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>{SERVICE_LABELS[p]}</Text>
{status(p, p === 'plex' ? 'Music you play from your Plex servers' : 'Live from your Spotify account')}
</View>
{isActive(p) ? (
<Button small title="Disconnect" onPress={() => disconnect(p)} />
) : (
<Button small variant="primary" title={byProvider.has(p) ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth(p)} />
)}
{/* Spotify caps new apps at a handful of accounts, so Spotify comes in through
its own Last.fm / ListenBrainz link (or the Android toggle). */}
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Spotify</Text>
<Body muted style={styles.small}>
{Platform.OS === 'android'
? 'Shared automatically by “This phone” above. On other devices, connect it through Last.fm or ListenBrainz.'
: spotifyLinked
? 'Through Spotify’s link to Last.fm or ListenBrainz. Make sure it’s linked there.'
: 'Connect it through Last.fm or ListenBrainz: Spotify sends your plays there, and we share them.'}
</Body>
</View>
))}
<Button small variant={spotifyLinked ? 'secondary' : 'primary'} title={spotifyLinked ? 'Check link' : 'Set up'} onPress={() => router.push('/spotify-setup')} />
</View>
<View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}>
<Text style={styles.provider}>Plex</Text>
{status('plex', 'Music you play from your Plex servers')}
</View>
{isActive('plex') ? (
<Button small title="Disconnect" onPress={() => disconnect('plex')} />
) : (
<Button small variant="primary" title={byProvider.has('plex') ? 'Reconnect' : 'Connect'} onPress={() => connectOAuth('plex')} />
)}
</View>
<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).')}
{status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')}
</View>
{byProvider.has('lastfm') ? (
<Button small title="Disconnect" onPress={() => disconnect('lastfm')} />
@@ -387,82 +364,6 @@ function JellyfinConnection({
);
}
function FeaturedPicker() {
const [q, setQ] = useState('');
const [selected, setSelected] = useState<TrackSearchResultDTO | null>(null);
const [comment, setComment] = useState('');
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
const query = useDebounced(q.trim(), 300);
const { data: results } = useSWR<TrackSearchResultDTO[]>(
query.length >= 2 && !selected ? `/tracks/search?q=${encodeURIComponent(query)}` : null,
);
const save = async () => {
if (!selected) return;
setBusy(true);
try {
await api.setFeatured({ deezerId: selected.deezerId, comment: comment.trim() || null });
setSaved(true);
} catch (err) {
showError(err);
}
setBusy(false);
};
return (
<Card>
<Heading>Featured song</Heading>
<Body muted style={styles.small}>
Pin a song to your profile, with an optional note about why.
</Body>
{selected ? (
<>
<View style={styles.result}>
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
<View style={{ flex: 1 }}>
<Text style={styles.provider} numberOfLines={1}>
{selected.title}
</Text>
<Body muted style={styles.small}>
{selected.artist}
</Body>
</View>
<Button small title="Change" onPress={() => (setSelected(null), setSaved(false))} />
</View>
<Field
placeholder="Say something about it (optional)"
value={comment}
onChangeText={setComment}
maxLength={280}
multiline
style={{ minHeight: 64 }}
/>
<Button
variant="primary"
title={saved ? 'Featured ✓' : 'Feature this song'}
loading={busy}
onPress={save}
style={{ alignSelf: 'flex-start' }}
/>
</>
) : (
<>
<Field placeholder="Search for a song" value={q} onChangeText={setQ} autoCorrect={false} returnKeyType="search" />
{results?.map((r) => (
<Pressable key={r.deezerId} style={styles.result} onPress={() => setSelected(r)} accessibilityRole="button">
{r.artworkUrl && <Image source={r.artworkUrl} style={styles.thumb} />}
<Text style={styles.resultText} numberOfLines={1}>
{r.title} <Text style={{ color: colors.muted }}>· {r.artist}</Text>
</Text>
</Pressable>
))}
</>
)}
</Card>
);
}
function Account() {
const { me, signOut } = useSession();
const confirmDelete = () =>
+19 -5
View File
@@ -1,3 +1,4 @@
import { SpaceGrotesk_500Medium, SpaceGrotesk_700Bold, useFonts } from '@expo-google-fonts/space-grotesk';
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
@@ -33,17 +34,20 @@ export default function RootLayout() {
function AppNavigator() {
const { status, me, retry } = useSession();
const signedIn = status === 'signedIn';
const onboarded = Boolean(me?.onboardedAt);
const [fontsLoaded, fontError] = useFonts({ SpaceGrotesk_500Medium, SpaceGrotesk_700Bold });
const ready = status !== 'loading' && (fontsLoaded || Boolean(fontError));
useEffect(() => {
if (status !== 'loading') SplashScreen.hideAsync();
}, [status]);
if (ready) SplashScreen.hideAsync();
}, [ready]);
// Keep the Android listener pointed at this account (no-op elsewhere).
useEffect(() => {
if (signedIn) syncDevice().catch(() => {});
}, [signedIn]);
if (status === 'loading') return null;
if (!ready) return null;
if (status === 'offline') {
return (
<View style={styles.offline}>
@@ -75,10 +79,20 @@ function AppNavigator() {
headerShadowVisible: false,
contentStyle: { backgroundColor: colors.bg },
}}>
<Stack.Protected guard={signedIn}>
<Stack.Protected guard={signedIn && onboarded}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="u/[username]" options={{ title: '' }} />
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
<Stack.Screen name="qr" options={{ title: 'My code', presentation: 'modal' }} />
<Stack.Screen name="spotify-setup" options={{ headerShown: false, presentation: 'modal' }} />
</Stack.Protected>
{/* New accounts go through onboarding first (finishing flips onboardedAt and lands on the feed). */}
<Stack.Protected guard={signedIn && !onboarded}>
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
</Stack.Protected>
<Stack.Protected guard={signedIn}>
{/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
<Stack.Screen name="u/[username]" options={{ title: '' }} />
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} />
@@ -0,0 +1,6 @@
import { Stack } from 'expo-router';
import { colors } from '@/lib/theme';
export default function OnboardingLayout() {
return <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg }, animation: 'slide_from_right' }} />;
}
@@ -0,0 +1,95 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { Pressable, Share, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import { profileUrl, type UserSummaryDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { OnboardingStep } from '@/components/OnboardingStep';
import { Body, Button, Field, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { WEB_URL } from '@/lib/config';
import { showError } from '@/lib/errors';
import { successHaptic } from '@/lib/haptics';
import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
import { useDebounced } from '@/lib/useDebounced';
/** Step 3: an empty feed is the fastest way to lose someone, so find friends now. */
export default function FriendsStep() {
const { me } = useSession();
const [q, setQ] = useState('');
const [followed, setFollowed] = useState<Set<string>>(new Set());
const query = useDebounced(q.trim(), 250);
const { data: results } = useSWR<UserSummaryDTO[]>(query.length >= 2 ? `/users/search?q=${encodeURIComponent(query)}` : null);
const follow = async (username: string) => {
try {
await api.follow(username);
successHaptic();
setFollowed((s) => new Set(s).add(username));
} catch (err) {
showError(err);
}
};
const invite = () => {
if (!me) return;
const url = profileUrl(WEB_URL, me.username);
Share.share({ message: `I'm on musicshare: follow me and see what I'm listening to → ${url}`, url }).catch(() => {});
};
return (
<OnboardingStep step={3}>
<View style={{ gap: space.sm }}>
<Title>Find your friends</Title>
<Body muted>Follow people to see what they’re playing, live.</Body>
</View>
<Row>
<Button small variant="primary" title="Scan a code" onPress={() => router.push('/scan')} />
<Button small title="Invite friends" onPress={invite} />
</Row>
<View style={{ gap: space.sm }}>
<Field placeholder="Search by name or username" value={q} onChangeText={setQ} autoCapitalize="none" autoCorrect={false} />
{results
?.filter((u) => u.id !== me?.id)
.slice(0, 6)
.map((u) => (
<View key={u.id} style={styles.row}>
<Pressable style={styles.who} onPress={() => router.push(`/u/${u.username}`)}>
<Avatar user={u} size={36} />
<View style={{ flexShrink: 1 }}>
<Text style={styles.name} numberOfLines={1}>
{u.displayName ?? u.username}
</Text>
<Text style={styles.handle}>@{u.username}</Text>
</View>
</Pressable>
<Button
small
variant={followed.has(u.username) ? 'secondary' : 'primary'}
title={followed.has(u.username) ? 'Following' : 'Follow'}
disabled={followed.has(u.username)}
onPress={() => follow(u.username)}
/>
</View>
))}
{query.length >= 2 && results?.length === 0 && <Body muted>No one by that name yet. Invite them!</Body>}
</View>
<Button
variant={followed.size ? 'primary' : 'secondary'}
title={followed.size ? `Continue (${followed.size} followed)` : 'Skip for now'}
onPress={() => router.push('/onboarding/song')}
/>
</OnboardingStep>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
who: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
handle: { color: colors.muted, fontSize: 13 },
});
+75
View File
@@ -0,0 +1,75 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { OnboardingStep } from '@/components/OnboardingStep';
import { Body, Button, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { selectionHaptic } from '@/lib/haptics';
import { LISTEN_APPS, finishOnboarding } from '@/lib/onboarding';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
/** Step 1: where do you listen? Sets where friends' songs open, and picks the right setup next. */
export default function ListenAppStep() {
const { me, setMe } = useSession();
const [picked, setPicked] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const next = async () => {
const app = LISTEN_APPS.find((a) => a.id === picked);
if (!app) return;
setBusy(true);
try {
if (app.service) setMe(await api.updateMe({ preferredService: app.service }));
router.push({ pathname: '/onboarding/share', params: { app: app.id } });
} catch (err) {
showError(err);
}
setBusy(false);
};
return (
<OnboardingStep step={1}>
<View style={{ gap: space.sm }}>
<Title>Hi {me?.displayName ?? me?.username} 👋</Title>
<Body muted>Which app do you mostly listen with?</Body>
</View>
<View style={styles.grid}>
{LISTEN_APPS.map((app, i) => (
<Animated.View key={app.id} entering={FadeInDown.delay(80 + i * 35).springify().damping(16)} style={styles.cell}>
<Pressable
accessibilityRole="radio"
accessibilityState={{ checked: picked === app.id }}
onPress={() => (selectionHaptic(), setPicked(app.id))}
style={[styles.option, picked === app.id && styles.optionPicked]}>
<Text style={[styles.optionText, picked === app.id && styles.optionTextPicked]}>{app.label}</Text>
</Pressable>
</Animated.View>
))}
</View>
<View style={{ gap: space.sm }}>
<Button variant="primary" title="Continue" disabled={!picked} loading={busy} onPress={next} />
<Button title="Skip setup" onPress={() => finishOnboarding(setMe).catch(showError)} />
</View>
</OnboardingStep>
);
}
const styles = StyleSheet.create({
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: space.sm },
cell: { width: '48.5%' },
option: {
minHeight: 56,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
justifyContent: 'center',
paddingHorizontal: space.md,
},
optionPicked: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.18)' },
optionText: { color: colors.text, fontWeight: '600', fontSize: 15 },
optionTextPicked: { color: '#fff' },
});
+41
View File
@@ -0,0 +1,41 @@
import { router, useLocalSearchParams } from 'expo-router';
import { Platform } from 'react-native';
import { OnboardingStep } from '@/components/OnboardingStep';
import { AndroidShareSetup } from '@/components/setup/AndroidShareSetup';
import { AppleMusicSetup } from '@/components/setup/AppleMusicSetup';
import { ServiceSignInSetup } from '@/components/setup/ServiceSignInSetup';
import { SpotifySetup } from '@/components/setup/SpotifySetup';
import { Points, SetupActions, SetupHeader } from '@/components/setup/common';
import { listenApp } from '@/lib/onboarding';
const next = () => router.push('/onboarding/friends');
/**
* Step 2: share what you play, matched to the app and the phone.
* Android: one toggle covers every app. iPhone: depends on the service.
*/
export default function ShareStep() {
const { app: appId } = useLocalSearchParams<{ app?: string }>();
const app = listenApp(appId);
const props = { onDone: next, skipLabel: 'Skip for now' };
let body: React.ReactNode;
if (Platform.OS === 'android') body = <AndroidShareSetup {...props} />;
else if (app.id === 'apple_music') body = <AppleMusicSetup {...props} />;
else if (app.id === 'spotify') body = <SpotifySetup {...props} />;
else if (app.id === 'plex') body = <ServiceSignInSetup service="plex" appName="Plex" {...props} />;
else if (app.id === 'jellyfin') {
body = (
<>
<SetupHeader
title="Connect Jellyfin"
subtitle="Jellyfin needs your server address and login, so set it up in Settings → Music services whenever you're ready."
/>
<Points items={['Your password is only used once to get a token; it isn’t stored.']} />
<SetupActions primary="Continue" onPrimary={next} />
</>
);
} else body = <ServiceSignInSetup service="lastfm" appName={app.label} {...props} />;
return <OnboardingStep step={2}>{body}</OnboardingStep>;
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { View } from 'react-native';
import { FeaturedPicker } from '@/components/FeaturedPicker';
import { OnboardingStep } from '@/components/OnboardingStep';
import { Body, Button, Title } from '@/components/ui';
import { showError } from '@/lib/errors';
import { successHaptic } from '@/lib/haptics';
import { finishOnboarding } from '@/lib/onboarding';
import { useSession } from '@/lib/session';
import { space } from '@/lib/theme';
/** Step 4 (optional): a song on your profile, so it isn't empty for the friends you invite. */
export default function SongStep() {
const { setMe } = useSession();
const [featured, setFeatured] = useState(false);
const [busy, setBusy] = useState(false);
// Finishing flips the navigator to the main app automatically.
const finish = async () => {
setBusy(true);
try {
await finishOnboarding(setMe);
successHaptic();
} catch (err) {
showError(err);
setBusy(false);
}
};
return (
<OnboardingStep step={4}>
<View style={{ gap: space.sm }}>
<Title>Pick your song</Title>
<Body muted>One song that says something about you. It goes at the top of your profile, with a note if you like.</Body>
</View>
<FeaturedPicker bare onSaved={() => setFeatured(true)} />
<Button variant={featured ? 'primary' : 'secondary'} title={featured ? 'Finish' : 'Skip and finish'} loading={busy} onPress={finish} />
</OnboardingStep>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { router } from 'expo-router';
import { Share, StyleSheet, Text, View } from 'react-native';
import { profileUrl } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { QrCode } from '@/components/QrCode';
import { Screen } from '@/components/Screen';
import { Body, Button } from '@/components/ui';
import { WEB_URL } from '@/lib/config';
import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
/** "My code": friends scan it (in musicshare or any camera app) to open your profile. */
export default function MyQrScreen() {
const { me } = useSession();
if (!me) return null;
const url = profileUrl(WEB_URL, me.username);
const share = () =>
Share.share({ message: `Follow me on musicshare and see what I'm listening to: ${url}`, url }).catch(() => {});
return (
<Screen edges={['bottom']} contentContainerStyle={styles.content}>
<View style={styles.who}>
<Avatar user={me} size={56} />
<Text style={styles.name}>{me.displayName ?? me.username}</Text>
<Text style={styles.handle}>@{me.username}</Text>
</View>
<QrCode value={url} size={260} />
<Body muted style={styles.hint}>
Friends scan this with musicshare (or their camera app) to open your profile.
</Body>
<View style={styles.actions}>
<Button variant="primary" title="Scan a friend’s code" onPress={() => router.replace('/scan')} />
<Button title="Share link" onPress={share} />
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', gap: space.xl },
who: { alignItems: 'center', gap: space.xs },
name: { color: colors.text, fontSize: 22, fontWeight: '800' },
handle: { color: colors.muted, fontSize: 15 },
hint: { textAlign: 'center', maxWidth: 300 },
actions: { alignSelf: 'stretch', gap: space.sm },
});
+81
View File
@@ -0,0 +1,81 @@
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { router } from 'expo-router';
import { useRef, useState } from 'react';
import { Linking, StyleSheet, Text, View } from 'react-native';
import { parseProfileLink } from '@musicshare/shared';
import { Screen } from '@/components/Screen';
import { Body, Button, Title } from '@/components/ui';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
/** Scan a friend's musicshare QR code and open their profile (with its Follow button). */
export default function ScanScreen() {
const { me } = useSession();
const [permission, requestPermission] = useCameraPermissions();
const [message, setMessage] = useState<string | null>(null);
// The camera reports the same code many times a second; act on the first.
const handled = useRef(false);
const onScanned = ({ data }: BarcodeScanningResult) => {
if (handled.current) return;
const username = parseProfileLink(data);
if (!username) {
setMessage('That’s not a musicshare code.');
return;
}
if (username === me?.username) {
setMessage('That’s your own code. Show it to a friend instead!');
return;
}
handled.current = true;
router.replace(`/u/${username}`);
};
if (!permission) return <Screen />;
if (!permission.granted) {
return (
<Screen edges={['bottom']} contentContainerStyle={styles.centered}>
<Title>Scan a friend’s code</Title>
<Body muted style={{ textAlign: 'center' }}>
musicshare needs the camera to scan QR codes. It’s only used while this screen is open.
</Body>
{permission.canAskAgain ? (
<Button variant="primary" title="Allow camera" onPress={requestPermission} />
) : (
<Button variant="primary" title="Open Settings" onPress={() => Linking.openSettings()} />
)}
<Button title="Show my code instead" onPress={() => router.replace('/qr')} />
</Screen>
);
}
return (
<View style={styles.fill}>
<CameraView style={styles.fill} facing="back" barcodeScannerSettings={{ barcodeTypes: ['qr'] }} onBarcodeScanned={onScanned} />
<View style={styles.overlay} pointerEvents="box-none">
<View style={styles.frame} />
<Text style={styles.prompt}>{message ?? 'Point at a friend’s musicshare code'}</Text>
<Button title="Show my code" onPress={() => router.replace('/qr')} />
</View>
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1, backgroundColor: '#000' },
centered: { flexGrow: 1, justifyContent: 'center', gap: space.lg },
overlay: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center', gap: space.xl, padding: space.xl },
frame: { width: 240, height: 240, borderRadius: radius.lg, borderWidth: 3, borderColor: colors.accentText },
prompt: {
color: colors.accentText,
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
backgroundColor: 'rgba(0,0,0,0.55)',
paddingHorizontal: space.md,
paddingVertical: space.sm,
borderRadius: radius.md,
overflow: 'hidden',
},
});
+7 -113
View File
@@ -1,132 +1,26 @@
import { router } from 'expo-router';
import { useEffect, useState } from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import { mutate } from 'swr';
import { useEffect } from 'react';
import { Platform, StyleSheet } from 'react-native';
import { Screen } from '@/components/Screen';
import { Body, Button, Card, Title } from '@/components/ui';
import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
import { AndroidShareSetup } from '@/components/setup/AndroidShareSetup';
import { AppleMusicSetup } from '@/components/setup/AppleMusicSetup';
import { markSetupSeen } from '@/lib/setup';
import { colors, space } from '@/lib/theme';
import { space } from '@/lib/theme';
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
/** Standalone "share what you're playing" (from the feed card), outside onboarding. */
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;
syncDevice()
.catch((err) => showError(err, "Couldn't finish setup"))
.finally(() => setSynced(true));
}, [granted]);
return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={{ gap: space.md }}>
<Title>{granted ? "You're all set" : 'Share what you’re playing'}</Title>
{granted ? (
<Body muted>
{playback?.title
? `Friends can see you're listening to ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''}.`
: 'Play something in Spotify, YouTube Music, Apple Music or any music app and your friends will see it.'}
</Body>
) : (
<Body muted>
Works with Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more. No accounts to link.
</Body>
)}
</View>
{!granted && (
<Card>
<Body style={styles.point}>• We read the song, artist and album from music apps, and nothing else.</Body>
<Body style={styles.point}>• We never see your notifications, messages or other apps.</Body>
<Body style={styles.point}>• Android will call this “notification access”. That’s the only way it lets apps see what’s playing.</Body>
<Body style={styles.point}>• Turn it off any time in Settings.</Body>
</Card>
)}
<View style={{ gap: space.sm }}>
{granted ? (
<Button variant="primary" title="Done" loading={finishing} onPress={close} />
) : (
<>
<Button variant="primary" title="Turn on" onPress={openReaderSettings} />
<Button title="Not now" onPress={close} />
</>
)}
</View>
{Platform.OS === 'ios' ? <AppleMusicSetup onDone={close} /> : <AndroidShareSetup onDone={close} />}
</Screen>
);
}
const styles = StyleSheet.create({
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
point: { color: colors.text, fontSize: 14 },
});
+20
View File
@@ -0,0 +1,20 @@
import { router } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Screen } from '@/components/Screen';
import { SpotifySetup } from '@/components/setup/SpotifySetup';
import { space } from '@/lib/theme';
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
/** Spotify via Last.fm or ListenBrainz, opened from Settings. */
export default function SpotifySetupScreen() {
return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<SpotifySetup onDone={close} />
</Screen>
);
}
const styles = StyleSheet.create({
content: { flexGrow: 1, justifyContent: 'center', gap: space.lg },
});