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