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 },
});
@@ -0,0 +1,36 @@
import { Image } from 'expo-image';
import { useEffect } from 'react';
import { StyleSheet, useWindowDimensions, View } from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
import { colors } from '@/lib/theme';
const AnimatedImage = Animated.createAnimatedComponent(Image);
/** Two soft colour blobs drifting slowly behind the content, so screens never feel static. */
export function AmbientBackground() {
const { width, height } = useWindowDimensions();
const t = useSharedValue(0);
useEffect(() => {
t.set(withRepeat(withTiming(1, { duration: 18000, easing: Easing.inOut(Easing.sin) }), -1, true));
}, [t]);
const size = Math.max(width, height) * 0.9;
const purple = useAnimatedStyle(() => ({
transform: [{ translateX: -size * 0.35 + t.value * width * 0.35 }, { translateY: -size * 0.3 + t.value * height * 0.12 }],
}));
const blue = useAnimatedStyle(() => ({
transform: [{ translateX: width - size * 0.6 - t.value * width * 0.3 }, { translateY: height - size * 0.55 - t.value * height * 0.1 }],
}));
return (
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.bg }]} pointerEvents="none">
<AnimatedImage source={require('@/assets/images/ambient-purple.png')} style={[styles.blob, { width: size, height: size }, purple]} />
<AnimatedImage source={require('@/assets/images/ambient-blue.png')} style={[styles.blob, { width: size, height: size }, blue]} />
</View>
);
}
const styles = StyleSheet.create({
blob: { position: 'absolute', top: 0, left: 0, opacity: 0.45 },
});
+4 -1
View File
@@ -1,3 +1,4 @@
import { Image } from 'expo-image';
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, StyleSheet, View } from 'react-native';
@@ -32,7 +33,8 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
<Screen edges={mode === 'login' ? ['top'] : []} contentContainerStyle={styles.content}>
{mode === 'login' && (
<View style={{ gap: space.sm }}>
<Title style={{ fontSize: 34 }}>musicshare</Title>
<Image source={require('@/assets/images/icon.png')} style={styles.logo} />
<Title style={{ fontSize: 36 }}>musicshare</Title>
<Body muted>See what your friends are playing, and open it in your own app.</Body>
</View>
)}
@@ -85,4 +87,5 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
const styles = StyleSheet.create({
content: { flexGrow: 1, justifyContent: 'center' },
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm },
});
+36 -7
View File
@@ -1,19 +1,48 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
import type { UserSummaryDTO } from '@musicshare/shared';
import { colors } from '@/lib/theme';
import { colors, fonts } from '@/lib/theme';
/** `live` wraps the avatar in a spinning gradient ring (someone is listening right now). */
export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?: number; live?: boolean }) {
const rotation = useSharedValue(0);
useEffect(() => {
if (live) rotation.set(withRepeat(withTiming(360, { duration: 3500, easing: Easing.linear }), -1, false));
else cancelAnimation(rotation);
return () => cancelAnimation(rotation);
}, [live, rotation]);
const spin = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }] }));
export function Avatar({ user, size = 40 }: { user: UserSummaryDTO; size?: number }) {
const shape = { width: size, height: size, borderRadius: size / 2 };
if (user.avatarUrl) return <Image source={user.avatarUrl} style={shape} contentFit="cover" />;
return (
<View style={[styles.empty, shape]}>
const face = user.avatarUrl ? (
<Image source={user.avatarUrl} style={shape} contentFit="cover" />
) : (
<LinearGradient colors={['#4a1278', '#1872cf']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
</LinearGradient>
);
if (!live) return face;
const ring = size + 6;
return (
<View style={{ width: ring, height: ring, alignItems: 'center', justifyContent: 'center' }}>
<Animated.View style={[StyleSheet.absoluteFill, spin]}>
<LinearGradient
colors={['#7b2ff7', '#2f8cff', colors.live, '#7b2ff7']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{ width: ring, height: ring, borderRadius: ring / 2 }}
/>
</Animated.View>
<View style={{ borderRadius: (size + 2) / 2, borderWidth: 2, borderColor: colors.bg }}>{face}</View>
</View>
);
}
const styles = StyleSheet.create({
empty: { backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center' },
letter: { color: colors.muted, fontWeight: '700' },
empty: { alignItems: 'center', justifyContent: 'center' },
letter: { color: '#fff', fontFamily: fonts.display },
});
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { View } from 'react-native';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { colors } from '@/lib/theme';
const BARS = [
{ duration: 520, delay: 0 },
{ duration: 380, delay: 120 },
{ duration: 640, delay: 60 },
{ duration: 450, delay: 200 },
];
function Bar({ playing, duration, delay, color, height }: { playing: boolean; duration: number; delay: number; color: string; height: number }) {
const scale = useSharedValue(0.3);
useEffect(() => {
if (playing) {
scale.set(withDelay(delay, withRepeat(withTiming(1, { duration, easing: Easing.inOut(Easing.quad) }), -1, true)));
} else {
cancelAnimation(scale);
scale.set(withTiming(0.3, { duration: 200 }));
}
return () => cancelAnimation(scale);
}, [playing, duration, delay, scale]);
const style = useAnimatedStyle(() => ({ transform: [{ translateY: (height * (1 - scale.value)) / 2 }, { scaleY: scale.value }] }));
return <Animated.View style={[{ width: 3, height, borderRadius: 2, backgroundColor: color }, style]} />;
}
/** Four bars that dance while music is playing and rest when it isn't. */
export function Equalizer({ playing, color = colors.live, height = 12 }: { playing: boolean; color?: string; height?: number }) {
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 2, height }} accessibilityElementsHidden>
{BARS.map((b, i) => (
<Bar key={i} playing={playing} color={color} height={height} {...b} />
))}
</View>
);
}
@@ -0,0 +1,106 @@
import { Image } from 'expo-image';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { TrackSearchResultDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { colors, radius, space } from '@/lib/theme';
import { useDebounced } from '@/lib/useDebounced';
import { Body, Button, Card, Field, Heading } from './ui';
/**
* Search for a song and pin it to your profile with an optional note.
* `bare` drops the card and heading (for onboarding, which has its own title).
*/
export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: () => void }) {
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);
onSaved?.();
} catch (err) {
showError(err);
}
setBusy(false);
};
const body = (
<>
{selected ? (
<>
<View style={styles.result}>
{selected.artworkUrl && <Image source={selected.artworkUrl} style={styles.thumb} />}
<View style={{ flex: 1 }}>
<Text style={styles.title} 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>
))}
</>
)}
</>
);
if (bare) return <View style={{ gap: space.md }}>{body}</View>;
return (
<Card>
<Heading>Featured song</Heading>
<Body muted style={styles.small}>
Pin a song to your profile, with an optional note about why.
</Body>
{body}
</Card>
);
}
const styles = StyleSheet.create({
small: { fontSize: 13 },
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
resultText: { color: colors.text, flex: 1, fontSize: 14 },
thumb: { width: 40, height: 40, borderRadius: radius.sm },
});
+67 -16
View File
@@ -1,10 +1,23 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { useEffect, useRef } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
FadeIn,
FadeInDown,
FadeOut,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
} from 'react-native-reanimated';
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
import { colors, space } from '@/lib/theme';
import { successHaptic, tapHaptic } from '@/lib/haptics';
import { colors, radius, space } from '@/lib/theme';
import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer';
import { TrackCard } from './TrackCard';
import { Card } from './ui';
export function NowPlayingCard({
np,
@@ -19,20 +32,43 @@ export function NowPlayingCard({
pinned?: boolean;
onTogglePin?: () => void;
}) {
// Star "pop" when pinned.
const star = useSharedValue(1);
const wasPinned = useRef(pinned);
useEffect(() => {
if (pinned && !wasPinned.current) star.set(withSequence(withSpring(1.6, { damping: 6, stiffness: 400 }), withSpring(1)));
wasPinned.current = pinned;
}, [pinned, star]);
const starStyle = useAnimatedStyle(() => ({ transform: [{ scale: star.value }] }));
const togglePin = () => {
if (pinned) tapHaptic();
else successHaptic();
onTogglePin?.();
};
return (
<Card highlighted={pinned}>
<View style={[styles.card, pinned && styles.pinned]}>
{/* The card glows with its album art; crossfades when the song changes. */}
{np.track.artworkUrl && (
<Animated.View key={np.track.artworkUrl} style={StyleSheet.absoluteFill} entering={FadeIn.duration(700)} exiting={FadeOut.duration(500)}>
<Image source={np.track.artworkUrl} style={styles.backdrop} blurRadius={40} contentFit="cover" />
</Animated.View>
)}
<LinearGradient colors={['rgba(7,7,11,0.05)', 'rgba(7,7,11,0.6)']} style={StyleSheet.absoluteFill} />
<View style={styles.header}>
{showUser && (
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
<Avatar user={np.user} size={28} />
<Avatar user={np.user} size={30} live={np.isPlaying} />
<Text style={styles.name} numberOfLines={1}>
{np.user.displayName ?? np.user.username}
</Text>
</Pressable>
)}
<View style={styles.statusRow}>
{np.isPlaying && <View style={styles.dot} />}
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
{np.isPlaying && <Equalizer playing />}
<Text style={[styles.status, np.isPlaying && styles.live]} numberOfLines={1}>
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
<Text
style={[styles.via, np.sourceUrl && styles.viaLink]}
@@ -43,29 +79,44 @@ export function NowPlayingCard({
</Text>
{onTogglePin && (
<Pressable
onPress={onTogglePin}
hitSlop={10}
onPress={togglePin}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel={pinned ? 'Unpin from top of feed' : 'Pin to top of feed'}
accessibilityState={{ selected: pinned }}>
<Text style={[styles.pin, pinned && { color: colors.pin }]}>{pinned ? '★' : '☆'}</Text>
<Animated.Text style={[styles.pin, pinned && styles.pinOn, starStyle]}>{pinned ? '★' : '☆'}</Animated.Text>
</Pressable>
)}
</View>
</View>
<TrackCard track={np.track} preferred={preferred} />
</Card>
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
<TrackCard track={np.track} preferred={preferred} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: radius.lg,
padding: space.lg,
gap: space.md,
overflow: 'hidden',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
pinned: { borderColor: 'rgba(251,191,36,0.55)' },
backdrop: { position: 'absolute', top: '-40%', left: '-40%', width: '180%', height: '180%', opacity: 0.55 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs, flexShrink: 0 },
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.live },
status: { color: colors.muted, fontSize: 13 },
via: { color: colors.muted },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs + 2, flexShrink: 1 },
status: { color: colors.muted, fontSize: 13, flexShrink: 1 },
live: { color: colors.live, fontWeight: '600' },
via: { color: colors.muted, fontWeight: '400' },
viaLink: { textDecorationLine: 'underline' },
pin: { color: colors.muted, fontSize: 20, marginLeft: space.xs },
pin: { color: colors.muted, fontSize: 21, marginLeft: space.xs },
pinOn: { color: colors.pin, textShadowColor: 'rgba(251,191,36,0.6)', textShadowRadius: 10 },
});
@@ -0,0 +1,31 @@
import { StyleSheet, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, radius, space } from '@/lib/theme';
import { Screen } from './Screen';
const TOTAL = 4;
/** Shared frame for onboarding screens: progress dots and an entrance animation. */
export function OnboardingStep({ step, children }: { step: number; children: React.ReactNode }) {
return (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={styles.dots} accessibilityLabel={`Step ${step} of ${TOTAL}`}>
{Array.from({ length: TOTAL }, (_, i) => (
<View key={i} style={[styles.dot, i < step && styles.dotDone, i === step - 1 && styles.dotCurrent]} />
))}
</View>
<Animated.View entering={FadeInDown.duration(450).springify().damping(18)} style={styles.body}>
{children}
</Animated.View>
</Screen>
);
}
const styles = StyleSheet.create({
content: { flexGrow: 1, gap: space.xl, paddingTop: space.xl },
body: { flex: 1, gap: space.xl, justifyContent: 'center' },
dots: { flexDirection: 'row', gap: space.sm, alignSelf: 'center' },
dot: { width: 8, height: 8, borderRadius: radius.pill, backgroundColor: colors.surface2 },
dotDone: { backgroundColor: colors.accent },
dotCurrent: { width: 24, backgroundColor: colors.accent2 },
});
+19
View File
@@ -0,0 +1,19 @@
import { useMemo } from 'react';
import { View } from 'react-native';
import Svg, { Path, Rect } from 'react-native-svg';
import { qrShape } from '@musicshare/shared';
/** Black-on-white QR code (scanners need the contrast) with the standard 4-module quiet zone. */
export function QrCode({ value, size = 240 }: { value: string; size?: number }) {
const shape = useMemo(() => qrShape(value), [value]);
const quiet = 4;
const total = shape.size + quiet * 2;
return (
<View accessible accessibilityLabel={`QR code for ${value}`} style={{ width: size, height: size, borderRadius: 16, overflow: 'hidden' }}>
<Svg width={size} height={size} viewBox={`${-quiet} ${-quiet} ${total} ${total}`}>
<Rect x={-quiet} y={-quiet} width={total} height={total} fill="#ffffff" />
<Path d={shape.path} fill="#000000" />
</Svg>
</View>
);
}
+2
View File
@@ -1,6 +1,7 @@
import { RefreshControl, ScrollView, StyleSheet, type ScrollViewProps } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, space } from '@/lib/theme';
import { AmbientBackground } from './AmbientBackground';
/** Scrollable, safe-area-aware page with optional pull-to-refresh. */
export function Screen({
@@ -13,6 +14,7 @@ export function Screen({
}: ScrollViewProps & { onRefresh?: () => void; refreshing?: boolean; edges?: ('top' | 'bottom')[] }) {
return (
<SafeAreaView style={styles.safe} edges={edges}>
<AmbientBackground />
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={[styles.content, contentContainerStyle]}
+26 -9
View File
@@ -2,7 +2,7 @@ import { Image } from 'expo-image';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { showError } from '@/lib/errors';
import { colors, radius, space } from '@/lib/theme';
import { colors, fonts, radius, space } from '@/lib/theme';
import { Button } from './ui';
/**
@@ -46,7 +46,7 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St
const { url, exact } = openUrl(track, s);
return (
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
<Text style={[styles.service, !exact && { opacity: 0.6 }]}>{SERVICE_LABELS[s]}</Text>
<Text style={[styles.service, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
</Pressable>
);
})}
@@ -57,15 +57,32 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St
}
const styles = StyleSheet.create({
track: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
artwork: { width: 88, height: 88, borderRadius: radius.md, backgroundColor: colors.surface2 },
artworkEmpty: { alignItems: 'center', justifyContent: 'center' },
note: { color: colors.muted, fontSize: 28 },
track: { flexDirection: 'row', gap: space.md + 2, alignItems: 'flex-start' },
artwork: {
width: 96,
height: 96,
borderRadius: 14,
backgroundColor: colors.surface2,
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.12)',
},
artworkEmpty: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#3a1a6e' },
note: { color: 'rgba(255,255,255,0.85)', fontSize: 30 },
body: { flex: 1, minWidth: 0, gap: 2 },
title: { color: colors.text, fontWeight: '700', fontSize: 16 },
title: { color: colors.text, fontFamily: fonts.display, fontSize: 18, letterSpacing: -0.2 },
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
muted: { color: colors.muted },
primary: { alignSelf: 'flex-start', marginTop: space.sm },
services: { flexDirection: 'row', flexWrap: 'wrap', columnGap: space.md, rowGap: space.xs, marginTop: space.sm },
service: { color: colors.muted, fontSize: 12 },
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
service: {
color: colors.text,
fontSize: 12,
paddingHorizontal: space.sm + 2,
paddingVertical: 4,
borderRadius: radius.pill,
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.08)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.12)',
},
});
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { showError } from '@/lib/errors';
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
import { Points, SetupActions, SetupHeader, type SetupProps } from './common';
/**
* Android: one toggle covers every music app. This doubles as the prominent
* disclosure Google Play requires before the notification-access screen.
*/
export function AndroidShareSetup({ onDone, skipLabel }: SetupProps) {
const { granted, playback } = useReader();
const [synced, setSynced] = useState(false);
useEffect(() => {
if (!granted) return;
syncDevice()
.catch((err) => showError(err, "Couldn't finish setup"))
.finally(() => setSynced(true));
}, [granted]);
if (granted) {
return (
<>
<SetupHeader
title="You're all set"
subtitle={
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.'
}
/>
<SetupActions primary="Continue" loading={!synced} onPrimary={onDone} />
</>
);
}
return (
<>
<SetupHeader
title="Share what you’re playing"
subtitle="Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more all work automatically. No accounts to link."
/>
<Points
items={[
'We read the song, artist and album from music apps, and nothing else.',
'We never see your notifications, messages or other apps.',
'Android will call this “notification access”. That’s the only way it lets apps see what’s playing.',
'Turn it off any time in Settings.',
]}
/>
<SetupActions primary="Turn on" onPrimary={openReaderSettings} onSkip={onDone} skipLabel={skipLabel} />
</>
);
}
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { mutate } from 'swr';
import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
import { ErrorText, Points, SetupActions, SetupHeader, type SetupProps } from './common';
/** iPhone: Apple Music is one system prompt. */
export function AppleMusicSetup({ onDone, skipLabel }: SetupProps) {
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);
};
if (connected) {
return (
<>
<SetupHeader title="You're all set" subtitle="Play something in Apple Music and your friends will see it." />
<SetupActions primary="Continue" onPrimary={onDone} />
</>
);
}
return (
<>
<SetupHeader
title="Share what you’re playing"
subtitle="Connect Apple Music and your friends see what you play. It takes one tap."
/>
<Points items={['We see the songs you play in Apple Music, and nothing else.', 'Pause sharing or disconnect any time.']} />
<ErrorText>{error}</ErrorText>
<SetupActions
primary="Connect Apple Music"
loading={connecting}
onPrimary={connect}
onSkip={onDone}
skipLabel={skipLabel}
/>
</>
);
}
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Linking } from 'react-native';
import { mutate } from 'swr';
import { SERVICE_LABELS } from '@musicshare/shared';
import { SignInError, signInWithService } from '@/lib/connect';
import { Button } from '../ui';
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
type Provider = 'plex' | 'lastfm';
/**
* Sign in to Plex, or to Last.fm for apps that can send plays there.
* (Spotify has its own SpotifySetup: via Last.fm or ListenBrainz.)
*/
export function ServiceSignInSetup({
service,
appName,
onDone,
skipLabel,
}: SetupProps & { service: Provider; appName?: string }) {
const [done, setDone] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async () => {
setBusy(true);
setError(null);
try {
if ((await signInWithService(service)) === 'connected') {
await mutate('/connections');
setDone(true);
}
} catch (err) {
setError(err instanceof SignInError ? err.message : `Couldn't connect ${SERVICE_LABELS[service]}. Try again.`);
}
setBusy(false);
};
if (done) {
return (
<>
<SetupHeader
title="You're all set"
subtitle={`Play something on ${appName ?? SERVICE_LABELS[service]} and your friends will see it.`}
/>
<SetupActions primary="Continue" onPrimary={onDone} />
</>
);
}
if (service === 'lastfm') {
return (
<>
<SetupHeader
title="Connect Last.fm"
subtitle={`${appName ?? 'Your app'} doesn’t let other iPhone apps see what’s playing. If it can send plays to Last.fm (TIDAL can, in its settings), sign in to Last.fm and we’ll share them.`}
/>
<ErrorText>{error}</ErrorText>
<SetupActions primary="Sign in with Last.fm" loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
<Button small title="New to Last.fm? Create an account" onPress={() => Linking.openURL('https://www.last.fm/join')} />
</>
);
}
return (
<>
<SetupHeader title="Share what you’re playing" subtitle={`Sign in to ${SERVICE_LABELS[service]} and your friends see what you play.`} />
<ErrorText>{error}</ErrorText>
<SetupActions primary={`Connect ${SERVICE_LABELS[service]}`} loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
</>
);
}
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR, { mutate } from 'swr';
import type { ConnectionDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { successHaptic } from '@/lib/haptics';
import { colors, radius, space } from '@/lib/theme';
import { Body, Button, Field } from '../ui';
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
type Via = 'lastfm' | 'listenbrainz';
/**
* Spotify sends your plays to Last.fm or ListenBrainz (its own built-in link),
* and we read them from there. Spotify only lets small apps sign in a handful of
* accounts directly, so this is the route that works for everyone.
*/
export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
const { data: connections = [] } = useSWR<ConnectionDTO[]>('/connections');
const connected = (p: Via) => connections.some((c) => c.provider === p && c.status === 'active');
const [via, setVia] = useState<Via | null>(connected('listenbrainz') && !connected('lastfm') ? 'listenbrainz' : null);
const [stage, setStage] = useState<'choose' | 'link' | 'done'>(connected('lastfm') || connected('listenbrainz') ? 'link' : 'choose');
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const connectLastfm = async () => {
setBusy(true);
setError(null);
try {
if ((await signInWithService('lastfm')) === 'connected') {
await mutate('/connections');
setVia('lastfm');
setStage('link');
}
} catch (err) {
setError(err instanceof SignInError ? err.message : "Couldn't connect Last.fm. Try again.");
}
setBusy(false);
};
const connectListenBrainz = async () => {
setBusy(true);
setError(null);
try {
await api.connectUsername('listenbrainz', username.trim());
await mutate('/connections');
setStage('link');
} catch (err) {
setError((err as Error).message);
}
setBusy(false);
};
if (stage === 'done') {
return (
<>
<SetupHeader title="You're all set" subtitle="Play something on Spotify. It'll show up for your friends within a minute or so." />
<SetupActions primary="Continue" onPrimary={onDone} />
</>
);
}
if (stage === 'link') {
const name = via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm';
return (
<>
<SetupHeader
title="Last step: link Spotify"
subtitle={
via === 'listenbrainz'
? 'On ListenBrainz, connect Spotify under Music services and approve. Spotify then sends your plays there, and we pick them up.'
: 'On Last.fm, tap Connect next to “Spotify Scrobbling” and approve. Spotify then sends your plays there, and we pick them up.'
}
/>
<SetupActions
primary={`Open ${name}`}
onPrimary={() =>
openSpotifyLinkPage(via ?? 'lastfm').then(() => {
successHaptic();
setStage('done');
})
}
onSkip={onDone}
skipLabel="I'll do it later"
/>
</>
);
}
return (
<>
<SetupHeader
title="Connect Spotify"
subtitle="Spotify can send everything you play to Last.fm or ListenBrainz, and we share it from there. Pick one (both are free):"
/>
<Option
selected={via === 'lastfm'}
onPress={() => setVia('lastfm')}
title="Last.fm"
badge="Most popular"
detail="Sign in with one tap, then approve Spotify on Last.fm."
/>
<Option
selected={via === 'listenbrainz'}
onPress={() => setVia('listenbrainz')}
title="ListenBrainz"
badge="Open source"
detail="Free and run by a nonprofit. Enter your ListenBrainz username."
/>
{via === 'listenbrainz' && (
<Field placeholder="ListenBrainz username" value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} />
)}
<ErrorText>{error}</ErrorText>
<View style={{ gap: space.sm }}>
{via === 'lastfm' && <Button variant="primary" title="Sign in with Last.fm" loading={busy} onPress={connectLastfm} />}
{via === 'listenbrainz' && (
<Button variant="primary" title="Connect ListenBrainz" loading={busy} disabled={!username.trim()} onPress={connectListenBrainz} />
)}
<Button title={skipLabel ?? 'Not now'} onPress={onDone} />
</View>
<Body muted style={styles.small} onPress={() => Linking.openURL(via === 'listenbrainz' ? 'https://listenbrainz.org/login/' : 'https://www.last.fm/join')}>
No account yet? Create one free on {via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm'} first.
</Body>
</>
);
}
function Option({ selected, onPress, title, badge, detail }: { selected: boolean; onPress: () => void; title: string; badge: string; detail: string }) {
return (
<Pressable accessibilityRole="radio" accessibilityState={{ checked: selected }} onPress={onPress} style={[styles.option, selected && styles.optionSelected]}>
<View style={styles.optionHead}>
<Text style={styles.optionTitle}>{title}</Text>
<Text style={styles.badge}>{badge}</Text>
</View>
<Body muted style={styles.small}>
{detail}
</Body>
</Pressable>
);
}
const styles = StyleSheet.create({
option: { borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: space.md, gap: space.xs },
optionSelected: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.16)' },
optionHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
optionTitle: { color: colors.text, fontWeight: '700', fontSize: 16 },
badge: { color: colors.accent2, fontSize: 12, fontWeight: '700' },
small: { fontSize: 13 },
});
@@ -0,0 +1,59 @@
import { StyleSheet, View } from 'react-native';
import { colors, space } from '@/lib/theme';
import { Body, Button, Card, Title } from '../ui';
export interface SetupProps {
/** Called when the user finishes (connected) or moves on. */
onDone: () => void;
/** Label for the secondary "move on without connecting" button. */
skipLabel?: string;
}
export function SetupHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
<View style={{ gap: space.md }}>
<Title>{title}</Title>
<Body muted>{subtitle}</Body>
</View>
);
}
export function Points({ items }: { items: string[] }) {
return (
<Card>
{items.map((item) => (
<Body key={item} style={styles.point}>
• {item}
</Body>
))}
</Card>
);
}
export function SetupActions({
primary,
onPrimary,
loading,
onSkip,
skipLabel = 'Not now',
}: {
primary: string;
onPrimary: () => void;
loading?: boolean;
onSkip?: () => void;
skipLabel?: string;
}) {
return (
<View style={{ gap: space.sm }}>
<Button variant="primary" title={primary} loading={loading} onPress={onPrimary} />
{onSkip && <Button title={skipLabel} onPress={onSkip} />}
</View>
);
}
export const ErrorText = ({ children }: { children: string | null }) =>
children ? <Body style={{ color: colors.danger }}>{children}</Body> : null;
const styles = StyleSheet.create({
point: { color: colors.text, fontSize: 14 },
});
+96 -42
View File
@@ -1,4 +1,5 @@
import { forwardRef } from 'react';
import { LinearGradient } from 'expo-linear-gradient';
import { forwardRef, useState } from 'react';
import {
ActivityIndicator,
Pressable,
@@ -6,12 +7,15 @@ import {
Text,
TextInput,
View,
type GestureResponderEvent,
type PressableProps,
type TextInputProps,
type TextProps,
type ViewProps,
} from 'react-native';
import { colors, radius, space } from '@/lib/theme';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { tapHaptic } from '@/lib/haptics';
import { brandGradient, colors, fonts, radius, space } from '@/lib/theme';
export function Title({ style, ...props }: TextProps) {
return <Text style={[styles.title, style]} {...props} />;
@@ -33,6 +37,21 @@ export function Row({ style, ...props }: ViewProps) {
return <View style={[styles.row, style]} {...props} />;
}
/** Springy press feedback shared by buttons and tappable cards. */
export function usePressScale(to = 0.95) {
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
return {
style,
onPressIn: () => {
scale.set(withSpring(to, { damping: 15, stiffness: 400 }));
},
onPressOut: () => {
scale.set(withSpring(1, { damping: 12, stiffness: 300 }));
},
};
}
type Variant = 'primary' | 'secondary' | 'danger';
export function Button({
@@ -42,59 +61,83 @@ export function Button({
small,
style,
disabled,
onPress,
...props
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
const press = usePressScale();
const inactive = disabled || loading;
const handlePress = (e: GestureResponderEvent) => {
if (variant === 'primary') tapHaptic();
onPress?.(e);
};
const label = loading ? (
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
) : (
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
{title}
</Text>
);
return (
<Pressable
accessibilityRole="button"
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
small && styles.buttonSmall,
variant === 'primary' && styles.buttonPrimary,
variant === 'danger' && styles.buttonDanger,
(pressed || disabled) && { opacity: 0.7 },
style,
]}
{...props}>
{loading ? (
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
) : (
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
{title}
</Text>
)}
</Pressable>
<Animated.View style={[press.style, inactive && { opacity: 0.55 }, style]}>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: Boolean(inactive), busy: Boolean(loading) }}
disabled={inactive}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
onPress={handlePress}
style={[styles.button, small && styles.buttonSmall, variant === 'danger' && styles.buttonDanger, variant === 'primary' && styles.buttonPrimary]}
{...props}>
{variant === 'primary' && (
<LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />
)}
{label}
</Pressable>
</Animated.View>
);
}
export const Field = forwardRef<TextInput, TextInputProps & { label?: string }>(function Field(
{ label, style, ...props },
{ label, style, onFocus, onBlur, ...props },
ref,
) {
const [focused, setFocused] = useState(false);
return (
<View style={{ gap: space.xs }}>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput ref={ref} placeholderTextColor={colors.muted} style={[styles.input, style]} {...props} />
<TextInput
ref={ref}
placeholderTextColor={colors.muted}
style={[styles.input, focused && styles.inputFocused, style]}
onFocus={(e) => (setFocused(true), onFocus?.(e))}
onBlur={(e) => (setFocused(false), onBlur?.(e))}
{...props}
/>
</View>
);
});
export function Chip({ label, selected, onPress }: { label: string; selected?: boolean; onPress: () => void }) {
const press = usePressScale(0.92);
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={onPress}
style={[styles.chip, selected && styles.chipSelected]}>
<Text style={[styles.chipText, selected && { color: colors.accentText }]}>{label}</Text>
</Pressable>
<Animated.View style={press.style}>
<Pressable
accessibilityRole="button"
accessibilityState={{ selected }}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
onPress={onPress}
style={[styles.chip, selected && styles.chipSelected]}>
{selected && <LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />}
<Text style={[styles.chipText, selected && { color: colors.accentText }]}>{label}</Text>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
title: { color: colors.text, fontSize: 28, fontWeight: '800', letterSpacing: -0.5 },
heading: { color: colors.text, fontSize: 17, fontWeight: '700', marginBottom: space.xs },
title: { color: colors.text, fontSize: 30, fontFamily: fonts.display, letterSpacing: -0.6 },
heading: { color: colors.text, fontSize: 18, fontFamily: fonts.display, marginBottom: space.xs },
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
muted: { color: colors.muted },
card: {
@@ -104,28 +147,37 @@ const styles = StyleSheet.create({
borderRadius: radius.lg,
padding: space.lg,
gap: space.md,
overflow: 'hidden',
},
cardHighlighted: { borderColor: colors.accent, borderWidth: 1 },
cardHighlighted: { borderColor: 'rgba(251,191,36,0.55)', borderWidth: 1 },
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
button: {
minHeight: 44,
paddingHorizontal: space.lg,
minHeight: 46,
paddingHorizontal: space.lg + 2,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface2,
borderWidth: 1,
borderColor: colors.border,
borderColor: colors.borderStrong,
overflow: 'hidden',
},
buttonSmall: { minHeight: 36, paddingHorizontal: space.md + 2 },
buttonPrimary: {
borderColor: 'transparent',
shadowColor: '#5b4cf0',
shadowOpacity: 0.6,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
buttonSmall: { minHeight: 34, paddingHorizontal: space.md },
buttonPrimary: { backgroundColor: colors.accent, borderColor: colors.accent },
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
buttonTextSmall: { fontSize: 13 },
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
input: {
minHeight: 44,
paddingHorizontal: space.md,
minHeight: 48,
paddingHorizontal: space.md + 2,
paddingVertical: space.sm,
borderRadius: radius.md,
borderWidth: 1,
@@ -134,6 +186,7 @@ const styles = StyleSheet.create({
color: colors.text,
fontSize: 15,
},
inputFocused: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.10)' },
chip: {
paddingHorizontal: space.md,
paddingVertical: space.sm,
@@ -141,7 +194,8 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface2,
overflow: 'hidden',
},
chipSelected: { backgroundColor: colors.accent, borderColor: colors.accent },
chipSelected: { borderColor: 'transparent' },
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
});
+3
View File
@@ -8,3 +8,6 @@ import Constants from 'expo-constants';
const devHost = Constants.expoConfig?.hostUri?.split(':')[0];
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? (devHost ? `http://${devHost}:4000` : 'http://localhost:4000');
/** The website, for shareable profile links and QR codes. In development, the web app on port 3737. */
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? (devHost ? `http://${devHost}:3737` : API_URL.replace(/:4000$/, ':3737'));
+27
View File
@@ -0,0 +1,27 @@
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';
import { SPOTIFY_LINK_PAGES } from '@musicshare/shared';
import { api } from './api';
export type SignInResult = 'connected' | 'cancelled';
export class SignInError extends Error {}
/**
* Sign in on the service's own site in an auth browser and come back through
* the app's deep link.
*/
export async function signInWithService(provider: 'plex' | 'lastfm'): Promise<SignInResult> {
// 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 'cancelled';
const error = Linking.parse(result.url).queryParams?.error;
if (typeof error === 'string') throw new SignInError(error);
return 'connected';
}
/** Where users approve "Spotify Scrobbling" (Last.fm) or the Spotify link (ListenBrainz). */
export const openSpotifyLinkPage = (on: keyof typeof SPOTIFY_LINK_PAGES = 'lastfm') =>
WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[on]);
+9
View File
@@ -0,0 +1,9 @@
import * as Haptics from 'expo-haptics';
/** Light feedback for taps on primary actions. Never throws (some devices have no haptics). */
export const tapHaptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
/** A satisfying "done" for follow, pin, connect, scan. */
export const successHaptic = () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
export const selectionHaptic = () => Haptics.selectionAsync().catch(() => {});
+31
View File
@@ -0,0 +1,31 @@
import type { MeDTO, StreamingService } from '@musicshare/shared';
import { api } from './api';
import { markSetupSeen } from './setup';
/** What people pick on the first onboarding screen. `service` is where friends' songs open. */
export interface ListenApp {
id: string;
label: string;
service?: StreamingService;
}
export const LISTEN_APPS: ListenApp[] = [
{ id: 'spotify', label: 'Spotify', service: 'spotify' },
{ id: 'apple_music', label: 'Apple Music', service: 'apple_music' },
{ id: 'youtube_music', label: 'YouTube Music', service: 'youtube_music' },
{ id: 'tidal', label: 'TIDAL', service: 'tidal' },
{ id: 'deezer', label: 'Deezer', service: 'deezer' },
{ id: 'soundcloud', label: 'SoundCloud' },
{ id: 'amazon', label: 'Amazon Music' },
{ id: 'plex', label: 'Plex / Plexamp' },
{ id: 'jellyfin', label: 'Jellyfin' },
{ id: 'other', label: 'Something else' },
];
export const listenApp = (id: string | undefined) => LISTEN_APPS.find((a) => a.id === id) ?? LISTEN_APPS[LISTEN_APPS.length - 1]!;
/** Done (or skipped through): never show onboarding again, on any device. */
export async function finishOnboarding(setMe: (me: MeDTO) => void) {
await markSetupSeen();
setMe(await api.updateMe({ onboarded: true }));
}
+23 -12
View File
@@ -1,17 +1,28 @@
/** Mirrors the web palette (apps/web/src/app/globals.css). */
/** Mirrors the web palette (apps/web/src/app/globals.css). Brand colours come from the logo. */
export const colors = {
bg: '#0e0f13',
surface: '#171920',
surface2: '#20232c',
border: '#2a2e39',
text: '#eceef3',
muted: '#9097a6',
accent: '#7c5cff',
bg: '#07070b',
surface: 'rgba(255,255,255,0.05)',
surfaceSolid: '#12121a',
surface2: 'rgba(255,255,255,0.09)',
border: 'rgba(255,255,255,0.10)',
borderStrong: 'rgba(255,255,255,0.18)',
text: '#f4f3fa',
muted: '#9b99ae',
accent: '#7c4dff',
accent2: '#2f8cff',
accentText: '#ffffff',
live: '#3ddc84',
danger: '#ff5c6c',
pin: '#f5c542',
live: '#34d399',
danger: '#fb7185',
pin: '#fbbf24',
} as const;
/** The logo's purple-to-blue, for buttons and highlights. */
export const brandGradient = ['#7b2ff7', '#5b4cf0', '#2f8cff'] as const;
export const fonts = {
display: 'SpaceGrotesk_700Bold',
displayMedium: 'SpaceGrotesk_500Medium',
} as const;
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
export const radius = { sm: 6, md: 10, lg: 14, pill: 999 } as const;
export const radius = { sm: 8, md: 12, lg: 20, pill: 999 } as const;