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
@@ -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 },
});