- 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
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
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, fonts, radius, space } from '@/lib/theme';
|
|
import { Button } from './ui';
|
|
|
|
/**
|
|
* Links are https universal/app links, so Linking.openURL hands them to the
|
|
* native Spotify / Apple Music / YouTube Music app when it's installed.
|
|
*/
|
|
const open = (url: string) => Linking.openURL(url).catch((e) => showError(e, "Couldn't open link"));
|
|
|
|
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
|
const primary = preferred ? openUrl(track, preferred) : null;
|
|
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
|
|
|
return (
|
|
<View style={styles.track}>
|
|
{track.artworkUrl ? (
|
|
<Image source={track.artworkUrl} style={styles.artwork} contentFit="cover" transition={150} />
|
|
) : (
|
|
<View style={[styles.artwork, styles.artworkEmpty]}>
|
|
<Text style={styles.note}>♪</Text>
|
|
</View>
|
|
)}
|
|
<View style={styles.body}>
|
|
<Text style={styles.title} numberOfLines={1}>
|
|
{track.title}
|
|
</Text>
|
|
<Text style={styles.artist} numberOfLines={1}>
|
|
{track.artist}
|
|
{track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
|
</Text>
|
|
{preferred && primary && (
|
|
<Button
|
|
small
|
|
variant="primary"
|
|
title={`${primary.exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`}
|
|
onPress={() => open(primary.url)}
|
|
style={styles.primary}
|
|
/>
|
|
)}
|
|
<View style={styles.services}>
|
|
{others.map((s) => {
|
|
const { url, exact } = openUrl(track, s);
|
|
return (
|
|
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
|
<Text style={[styles.service, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
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, 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', 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)',
|
|
},
|
|
});
|