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:
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user