Files
musicshare/apps/mobile/src/components/TrackCard.tsx
T
chrisandClaude Opus 5.5 edc0c7b999
CI / check (push) Canceled after 0s
Share songs, with link previews
- "Share ↗" on every song (web and mobile): the phone's share sheet, or
  copy the link on desktop. Links go to a new song page, /t/<id>?from=you,
  with big artwork and open-in buttons for every service.
- Server-rendered previews (Open Graph / Twitter cards) for song pages and
  profiles: title, who shared it or what they're playing, and the album
  art, avatar or logo. The web server fetches from the API over the
  compose network (INTERNAL_API_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-06 13:31:47 -04:00

103 lines
4.2 KiB
TypeScript

import { Image } from 'expo-image';
import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native';
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, shareText, trackUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { WEB_URL } from '@/lib/config';
import { useSession } from '@/lib/session';
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"));
/** The phone's share sheet, with a link to the song's page (which opens it in any service). */
function shareTrack(track: TrackDTO, from: string | undefined) {
const url = trackUrl(WEB_URL, track.id, from);
// Android shows only `message`; iOS shows `url` as a rich link next to it.
Share.share({ message: `${shareText(track)} ${url}`, url, title: shareText(track) }).catch((e) => showError(e, "Couldn't share"));
}
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
const { me } = useSession();
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>
);
})}
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
<Text style={[styles.service, styles.share]}>Share ↗</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 },
share: { backgroundColor: 'rgba(123,47,247,0.28)', borderColor: 'rgba(123,47,247,0.5)' },
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)',
},
});