diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
new file mode 100644
index 0000000..5817d21
--- /dev/null
+++ b/apps/mobile/app.config.ts
@@ -0,0 +1,20 @@
+import type { ConfigContext, ExpoConfig } from 'expo/config';
+
+/**
+ * app.json holds the real config; this only adds build-time switches.
+ *
+ * Local test builds point at a dev server over plain http://, which Android
+ * blocks by default in release builds. Allow cleartext only when the API URL
+ * itself is http://, so store builds (https) stay locked down.
+ */
+export default ({ config }: ConfigContext): ExpoConfig => {
+ const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? '';
+ const localHttp = apiUrl.startsWith('http://');
+ return {
+ ...(config as ExpoConfig),
+ plugins: [
+ ...(config.plugins ?? []),
+ ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }],
+ ],
+ };
+};
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 2c4421b..d16207d 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -8,7 +8,7 @@
"scheme": "musicshare",
"userInterfaceStyle": "dark",
"ios": {
- "icon": "./assets/expo.icon",
+ "icon": "./assets/images/icon.png",
"bundleIdentifier": "tech.chrisedwards.musicshare",
"infoPlist": {
"NSAppleMusicUsageDescription": "musicshare shows your friends what you play in Apple Music, and opens their songs for you."
@@ -16,7 +16,7 @@
},
"android": {
"adaptiveIcon": {
- "backgroundColor": "#0e0f13",
+ "backgroundColor": "#1a0a4a",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
@@ -30,9 +30,17 @@
[
"expo-splash-screen",
{
- "backgroundColor": "#0e0f13",
+ "backgroundColor": "#0b0820",
"image": "./assets/images/splash-icon.png",
- "imageWidth": 76
+ "imageWidth": 150
+ }
+ ],
+ [
+ "expo-camera",
+ {
+ "cameraPermission": "musicshare uses the camera to scan friends' QR codes.",
+ "microphonePermission": false,
+ "recordAudioAndroid": false
}
]
],
diff --git a/apps/mobile/assets/images/ambient-blue.png b/apps/mobile/assets/images/ambient-blue.png
new file mode 100644
index 0000000..1524ed1
Binary files /dev/null and b/apps/mobile/assets/images/ambient-blue.png differ
diff --git a/apps/mobile/assets/images/ambient-purple.png b/apps/mobile/assets/images/ambient-purple.png
new file mode 100644
index 0000000..c603188
Binary files /dev/null and b/apps/mobile/assets/images/ambient-purple.png differ
diff --git a/apps/mobile/assets/images/android-icon-background.png b/apps/mobile/assets/images/android-icon-background.png
index 5ffefc5..8f75e47 100644
Binary files a/apps/mobile/assets/images/android-icon-background.png and b/apps/mobile/assets/images/android-icon-background.png differ
diff --git a/apps/mobile/assets/images/android-icon-foreground.png b/apps/mobile/assets/images/android-icon-foreground.png
index 3a9e501..eeef14e 100644
Binary files a/apps/mobile/assets/images/android-icon-foreground.png and b/apps/mobile/assets/images/android-icon-foreground.png differ
diff --git a/apps/mobile/assets/images/android-icon-monochrome.png b/apps/mobile/assets/images/android-icon-monochrome.png
index 77484eb..eeef14e 100644
Binary files a/apps/mobile/assets/images/android-icon-monochrome.png and b/apps/mobile/assets/images/android-icon-monochrome.png differ
diff --git a/apps/mobile/assets/images/icon.png b/apps/mobile/assets/images/icon.png
index 67c777a..6c5516d 100644
Binary files a/apps/mobile/assets/images/icon.png and b/apps/mobile/assets/images/icon.png differ
diff --git a/apps/mobile/assets/images/splash-icon.png b/apps/mobile/assets/images/splash-icon.png
index 6b1642a..bf3a8da 100644
Binary files a/apps/mobile/assets/images/splash-icon.png and b/apps/mobile/assets/images/splash-icon.png differ
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 37b4fce..be490fd 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -3,14 +3,19 @@
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
+ "@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~57.0.21",
"@musicshare/shared": "*",
"expo": "~57.0.26",
+ "expo-build-properties": "~57.0.22",
+ "expo-camera": "~57.0.6",
"expo-constants": "~57.0.20",
"expo-device": "~57.0.2",
"expo-font": "~57.0.4",
"expo-glass-effect": "~57.0.4",
+ "expo-haptics": "~57.0.3",
"expo-image": "~57.0.5",
+ "expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11",
"expo-router": "~57.0.24",
"expo-secure-store": "~57.0.4",
@@ -26,6 +31,7 @@
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
+ "react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1",
"swr": "^2.5.1"
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
index 7dda3cc..37422b2 100644
--- a/apps/mobile/src/app/(tabs)/index.tsx
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -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 (
-
+ {/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
+ item.user.id}
contentContainerStyle={styles.list}
@@ -115,13 +120,15 @@ export default function FeedScreen() {
)
}
- renderItem={({ item }) => (
- togglePin(item)}
- />
+ renderItem={({ item, index }) => (
+
+ togglePin(item)}
+ />
+
)}
/>
diff --git a/apps/mobile/src/app/(tabs)/search.tsx b/apps/mobile/src/app/(tabs)/search.tsx
index 2a24032..6b0c2ac 100644
--- a/apps/mobile/src/app/(tabs)/search.tsx
+++ b/apps/mobile/src/app/(tabs)/search.tsx
@@ -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 (
+
= 2 ? (data ?? []) : []}
keyExtractor={(u) => u.id}
@@ -24,6 +26,10 @@ export default function SearchScreen() {
ListHeaderComponent={
Find friends
+
+
=> {
- // 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() {
)}
- {(['spotify', 'plex'] as const).map((p) => (
-
-
- {SERVICE_LABELS[p]}
- {status(p, p === 'plex' ? 'Music you play from your Plex servers' : 'Live from your Spotify account')}
-
- {isActive(p) ? (
- disconnect(p)} />
- ) : (
- 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). */}
+
+
+ Spotify
+
+ {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.'}
+
- ))}
+ router.push('/spotify-setup')} />
+
+
+
+
+ Plex
+ {status('plex', 'Music you play from your Plex servers')}
+
+ {isActive('plex') ? (
+ disconnect('plex')} />
+ ) : (
+ connectOAuth('plex')} />
+ )}
+
Last.fm
- {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.')}
{byProvider.has('lastfm') ? (
disconnect('lastfm')} />
@@ -387,82 +364,6 @@ function JellyfinConnection({
);
}
-function FeaturedPicker() {
- const [q, setQ] = useState('');
- const [selected, setSelected] = useState(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(
- 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 (
-
- Featured song
-
- Pin a song to your profile, with an optional note about why.
-
- {selected ? (
- <>
-
- {selected.artworkUrl && }
-
-
- {selected.title}
-
-
- {selected.artist}
-
-
- (setSelected(null), setSaved(false))} />
-
-
-
- >
- ) : (
- <>
-
- {results?.map((r) => (
- setSelected(r)} accessibilityRole="button">
- {r.artworkUrl && }
-
- {r.title} · {r.artist}
-
-
- ))}
- >
- )}
-
- );
-}
-
function Account() {
const { me, signOut } = useSession();
const confirmDelete = () =>
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 68d9455..8fc2575 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -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 (
@@ -75,10 +79,20 @@ function AppNavigator() {
headerShadowVisible: false,
contentStyle: { backgroundColor: colors.bg },
}}>
-
+
-
+
+
+
+ {/* New accounts go through onboarding first (finishing flips onboardedAt and lands on the feed). */}
+
+
+
+
+ {/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
+
+
diff --git a/apps/mobile/src/app/onboarding/_layout.tsx b/apps/mobile/src/app/onboarding/_layout.tsx
new file mode 100644
index 0000000..ac605c2
--- /dev/null
+++ b/apps/mobile/src/app/onboarding/_layout.tsx
@@ -0,0 +1,6 @@
+import { Stack } from 'expo-router';
+import { colors } from '@/lib/theme';
+
+export default function OnboardingLayout() {
+ return ;
+}
diff --git a/apps/mobile/src/app/onboarding/friends.tsx b/apps/mobile/src/app/onboarding/friends.tsx
new file mode 100644
index 0000000..c49c7f0
--- /dev/null
+++ b/apps/mobile/src/app/onboarding/friends.tsx
@@ -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>(new Set());
+ const query = useDebounced(q.trim(), 250);
+ const { data: results } = useSWR(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 (
+
+
+ Find your friends
+ Follow people to see what they’re playing, live.
+
+
+
+ router.push('/scan')} />
+
+
+
+
+
+ {results
+ ?.filter((u) => u.id !== me?.id)
+ .slice(0, 6)
+ .map((u) => (
+
+ router.push(`/u/${u.username}`)}>
+
+
+
+ {u.displayName ?? u.username}
+
+ @{u.username}
+
+
+ follow(u.username)}
+ />
+
+ ))}
+ {query.length >= 2 && results?.length === 0 && No one by that name yet. Invite them!}
+
+
+ router.push('/onboarding/song')}
+ />
+
+ );
+}
+
+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 },
+});
diff --git a/apps/mobile/src/app/onboarding/index.tsx b/apps/mobile/src/app/onboarding/index.tsx
new file mode 100644
index 0000000..10f4387
--- /dev/null
+++ b/apps/mobile/src/app/onboarding/index.tsx
@@ -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(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 (
+
+
+ Hi {me?.displayName ?? me?.username} 👋
+ Which app do you mostly listen with?
+
+
+ {LISTEN_APPS.map((app, i) => (
+
+ (selectionHaptic(), setPicked(app.id))}
+ style={[styles.option, picked === app.id && styles.optionPicked]}>
+ {app.label}
+
+
+ ))}
+
+
+
+ finishOnboarding(setMe).catch(showError)} />
+
+
+ );
+}
+
+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' },
+});
diff --git a/apps/mobile/src/app/onboarding/share.tsx b/apps/mobile/src/app/onboarding/share.tsx
new file mode 100644
index 0000000..fe71a59
--- /dev/null
+++ b/apps/mobile/src/app/onboarding/share.tsx
@@ -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 = ;
+ else if (app.id === 'apple_music') body = ;
+ else if (app.id === 'spotify') body = ;
+ else if (app.id === 'plex') body = ;
+ else if (app.id === 'jellyfin') {
+ body = (
+ <>
+
+
+
+ >
+ );
+ } else body = ;
+
+ return {body};
+}
diff --git a/apps/mobile/src/app/onboarding/song.tsx b/apps/mobile/src/app/onboarding/song.tsx
new file mode 100644
index 0000000..5d52e00
--- /dev/null
+++ b/apps/mobile/src/app/onboarding/song.tsx
@@ -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 (
+
+
+ Pick your song
+ One song that says something about you. It goes at the top of your profile, with a note if you like.
+
+ setFeatured(true)} />
+
+
+ );
+}
diff --git a/apps/mobile/src/app/qr.tsx b/apps/mobile/src/app/qr.tsx
new file mode 100644
index 0000000..5af1cf3
--- /dev/null
+++ b/apps/mobile/src/app/qr.tsx
@@ -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 (
+
+
+
+ {me.displayName ?? me.username}
+ @{me.username}
+
+
+
+ Friends scan this with musicshare (or their camera app) to open your profile.
+
+
+ router.replace('/scan')} />
+
+
+
+ );
+}
+
+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 },
+});
diff --git a/apps/mobile/src/app/scan.tsx b/apps/mobile/src/app/scan.tsx
new file mode 100644
index 0000000..60d3765
--- /dev/null
+++ b/apps/mobile/src/app/scan.tsx
@@ -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(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 ;
+
+ if (!permission.granted) {
+ return (
+
+ Scan a friend’s code
+
+ musicshare needs the camera to scan QR codes. It’s only used while this screen is open.
+
+ {permission.canAskAgain ? (
+
+ ) : (
+ Linking.openSettings()} />
+ )}
+ router.replace('/qr')} />
+
+ );
+ }
+
+ return (
+
+
+
+
+ {message ?? 'Point at a friend’s musicshare code'}
+ router.replace('/qr')} />
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/apps/mobile/src/app/share-setup.tsx b/apps/mobile/src/app/share-setup.tsx
index b281be7..9f97bbd 100644
--- a/apps/mobile/src/app/share-setup.tsx
+++ b/apps/mobile/src/app/share-setup.tsx
@@ -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' ? : ;
-}
-
-/** 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(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 (
-
- {connected ? "You're all set" : 'Share what you’re playing'}
-
- {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.'}
-
-
- {!connected && (
-
- • We see the songs you play in Apple Music, and nothing else.
- • Use Spotify or Plex? Connect them in Settings.
- • Pause sharing or disconnect any time.
-
- )}
- {error && {error}}
-
- {connected ? (
-
- ) : (
- <>
-
-
- >
- )}
-
-
- );
-}
-
-/**
- * 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 (
-
-
- {granted ? "You're all set" : 'Share what you’re playing'}
- {granted ? (
-
- {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.'}
-
- ) : (
-
- Works with Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more. No accounts to link.
-
- )}
-
-
- {!granted && (
-
- • 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.
-
- )}
-
-
- {granted ? (
-
- ) : (
- <>
-
-
- >
- )}
-
+ {Platform.OS === 'ios' ? : }
);
}
const styles = StyleSheet.create({
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
- point: { color: colors.text, fontSize: 14 },
});
diff --git a/apps/mobile/src/app/spotify-setup.tsx b/apps/mobile/src/app/spotify-setup.tsx
new file mode 100644
index 0000000..c6af963
--- /dev/null
+++ b/apps/mobile/src/app/spotify-setup.tsx
@@ -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 (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ content: { flexGrow: 1, justifyContent: 'center', gap: space.lg },
+});
diff --git a/apps/mobile/src/components/AmbientBackground.tsx b/apps/mobile/src/components/AmbientBackground.tsx
new file mode 100644
index 0000000..0649371
--- /dev/null
+++ b/apps/mobile/src/components/AmbientBackground.tsx
@@ -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 (
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ blob: { position: 'absolute', top: 0, left: 0, opacity: 0.45 },
+});
diff --git a/apps/mobile/src/components/AuthForm.tsx b/apps/mobile/src/components/AuthForm.tsx
index 7364a61..c002309 100644
--- a/apps/mobile/src/components/AuthForm.tsx
+++ b/apps/mobile/src/components/AuthForm.tsx
@@ -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' }) {
{mode === 'login' && (
- musicshare
+
+ musicshare
See what your friends are playing, and open it in your own app.
)}
@@ -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 },
});
diff --git a/apps/mobile/src/components/Avatar.tsx b/apps/mobile/src/components/Avatar.tsx
index 5570a0e..06a771a 100644
--- a/apps/mobile/src/components/Avatar.tsx
+++ b/apps/mobile/src/components/Avatar.tsx
@@ -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 ;
- return (
-
+ const face = user.avatarUrl ? (
+
+ ) : (
+
{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}
+
+ );
+ if (!live) return face;
+
+ const ring = size + 6;
+ return (
+
+
+
+
+ {face}
);
}
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 },
});
diff --git a/apps/mobile/src/components/Equalizer.tsx b/apps/mobile/src/components/Equalizer.tsx
new file mode 100644
index 0000000..6b468df
--- /dev/null
+++ b/apps/mobile/src/components/Equalizer.tsx
@@ -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 ;
+}
+
+/** 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 (
+
+ {BARS.map((b, i) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/src/components/FeaturedPicker.tsx b/apps/mobile/src/components/FeaturedPicker.tsx
new file mode 100644
index 0000000..5499c73
--- /dev/null
+++ b/apps/mobile/src/components/FeaturedPicker.tsx
@@ -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(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(
+ 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 ? (
+ <>
+
+ {selected.artworkUrl && }
+
+
+ {selected.title}
+
+
+ {selected.artist}
+
+
+ (setSelected(null), setSaved(false))} />
+
+
+
+ >
+ ) : (
+ <>
+
+ {results?.map((r) => (
+ setSelected(r)} accessibilityRole="button">
+ {r.artworkUrl && }
+
+ {r.title} · {r.artist}
+
+
+ ))}
+ >
+ )}
+ >
+ );
+
+ if (bare) return {body};
+ return (
+
+ Featured song
+
+ Pin a song to your profile, with an optional note about why.
+
+ {body}
+
+ );
+}
+
+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 },
+});
diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx
index 32de971..a413126 100644
--- a/apps/mobile/src/components/NowPlayingCard.tsx
+++ b/apps/mobile/src/components/NowPlayingCard.tsx
@@ -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 (
-
+
+ {/* The card glows with its album art; crossfades when the song changes. */}
+ {np.track.artworkUrl && (
+
+
+
+ )}
+
+
{showUser && (
router.push(`/u/${np.user.username}`)} accessibilityRole="link">
-
+
{np.user.displayName ?? np.user.username}
)}
- {np.isPlaying && }
-
+ {np.isPlaying && }
+
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
{onTogglePin && (
- {pinned ? '★' : '☆'}
+ {pinned ? '★' : '☆'}
)}
-
-
+
+
+
+
+
);
}
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 },
});
diff --git a/apps/mobile/src/components/OnboardingStep.tsx b/apps/mobile/src/components/OnboardingStep.tsx
new file mode 100644
index 0000000..1405da5
--- /dev/null
+++ b/apps/mobile/src/components/OnboardingStep.tsx
@@ -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 (
+
+
+ {Array.from({ length: TOTAL }, (_, i) => (
+
+ ))}
+
+
+ {children}
+
+
+ );
+}
+
+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 },
+});
diff --git a/apps/mobile/src/components/QrCode.tsx b/apps/mobile/src/components/QrCode.tsx
new file mode 100644
index 0000000..584d8ee
--- /dev/null
+++ b/apps/mobile/src/components/QrCode.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/Screen.tsx b/apps/mobile/src/components/Screen.tsx
index d9c37c8..62cc440 100644
--- a/apps/mobile/src/components/Screen.tsx
+++ b/apps/mobile/src/components/Screen.tsx
@@ -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 (
+
open(url)} hitSlop={6} accessibilityRole="link">
- {SERVICE_LABELS[s]}
+ {SERVICE_LABELS[s]}
);
})}
@@ -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)',
+ },
});
diff --git a/apps/mobile/src/components/setup/AndroidShareSetup.tsx b/apps/mobile/src/components/setup/AndroidShareSetup.tsx
new file mode 100644
index 0000000..654233c
--- /dev/null
+++ b/apps/mobile/src/components/setup/AndroidShareSetup.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/components/setup/AppleMusicSetup.tsx b/apps/mobile/src/components/setup/AppleMusicSetup.tsx
new file mode 100644
index 0000000..1e24c12
--- /dev/null
+++ b/apps/mobile/src/components/setup/AppleMusicSetup.tsx
@@ -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(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 (
+ <>
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+
+ {error}
+
+ >
+ );
+}
diff --git a/apps/mobile/src/components/setup/ServiceSignInSetup.tsx b/apps/mobile/src/components/setup/ServiceSignInSetup.tsx
new file mode 100644
index 0000000..9b9ba50
--- /dev/null
+++ b/apps/mobile/src/components/setup/ServiceSignInSetup.tsx
@@ -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(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 (
+ <>
+
+
+ >
+ );
+ }
+
+ if (service === 'lastfm') {
+ return (
+ <>
+
+ {error}
+
+ Linking.openURL('https://www.last.fm/join')} />
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {error}
+
+ >
+ );
+}
diff --git a/apps/mobile/src/components/setup/SpotifySetup.tsx b/apps/mobile/src/components/setup/SpotifySetup.tsx
new file mode 100644
index 0000000..f18a132
--- /dev/null
+++ b/apps/mobile/src/components/setup/SpotifySetup.tsx
@@ -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('/connections');
+ const connected = (p: Via) => connections.some((c) => c.provider === p && c.status === 'active');
+ const [via, setVia] = useState(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(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 (
+ <>
+
+
+ >
+ );
+ }
+
+ if (stage === 'link') {
+ const name = via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm';
+ return (
+ <>
+
+
+ openSpotifyLinkPage(via ?? 'lastfm').then(() => {
+ successHaptic();
+ setStage('done');
+ })
+ }
+ onSkip={onDone}
+ skipLabel="I'll do it later"
+ />
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+