Slim song cards with an expand arrow, and lyrics
Feed and profile cards now show one slim line (small artwork, title, artist, Open button) with the progress bar and reactions. The arrow opens the rest: album, your other services, Share, and lyrics. Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a self-hosted copy, or be empty to turn lyrics off). The server looks each song up once and caches it, including misses (retried after a week). Timed lyrics follow along with the song; otherwise the first lines show with "All lyrics". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { LinearTransition } from 'react-native-reanimated';
|
||||
import useSWR from 'swr';
|
||||
import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
|
||||
/** Lines shown around the current one while following along. */
|
||||
const BEFORE = 1;
|
||||
const AFTER = 3;
|
||||
/** Lines shown before "All lyrics" when there's nothing to follow. */
|
||||
const PREVIEW = 8;
|
||||
|
||||
/**
|
||||
* A song's lyrics. With timed lyrics and a live position (`np`), shows a few
|
||||
* lines around the one being sung and follows along; "All lyrics" shows the rest.
|
||||
*/
|
||||
export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
||||
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`);
|
||||
const [full, setFull] = useState(false);
|
||||
|
||||
if (isLoading) return <Text style={styles.note}>Finding lyrics…</Text>;
|
||||
if (error) return <Text style={styles.note}>Couldn't load lyrics right now.</Text>;
|
||||
if (!data) return <Text style={styles.note}>No lyrics found for this song.</Text>;
|
||||
if (data.instrumental && !data.synced && !data.plain) return <Text style={styles.note}>♪ Instrumental</Text>;
|
||||
|
||||
const synced = data.synced;
|
||||
const live = Boolean(np?.isPlaying && playbackPosition(np, 0));
|
||||
const following = Boolean(synced && live && !full);
|
||||
const text = synced ? synced.map((l) => l.text).join('\n') : (data.plain ?? '');
|
||||
const all = text.split('\n');
|
||||
const clipped = !following && !full && all.length > PREVIEW;
|
||||
const canToggle = (synced && live) || clipped || full;
|
||||
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<Pressable onPress={() => setFull((v) => !v)} disabled={!canToggle} accessibilityRole="button" accessibilityLabel={full ? 'Show fewer lyrics' : 'Show all lyrics'}>
|
||||
{following && synced && np ? (
|
||||
// Each fresh position from the server restarts the clock.
|
||||
<FollowAlong key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} lines={synced} np={np} />
|
||||
) : (
|
||||
(clipped ? all.slice(0, PREVIEW) : all).map((t, i) => (
|
||||
<Text key={i} style={[styles.line, styles.plain]}>
|
||||
{t}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footer}>
|
||||
{canToggle && (
|
||||
<Text style={styles.toggle} onPress={() => setFull((v) => !v)}>
|
||||
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.source} onPress={() => Linking.openURL(data.source.url)}>
|
||||
Lyrics from {data.source.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The line being sung, one before it and a few after, moving with the song. */
|
||||
function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) {
|
||||
const [receivedAt] = useState(() => Date.now());
|
||||
const [now, setNow] = useState(receivedAt);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0;
|
||||
const current = currentLyricIndex(lines, position);
|
||||
const from = Math.max(0, current - BEFORE);
|
||||
return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => {
|
||||
const index = from + i;
|
||||
const state = index < current ? styles.past : index === current ? styles.now : styles.next;
|
||||
return (
|
||||
<Animated.Text key={`${l.timeMs}-${index}`} layout={LinearTransition.duration(300)} style={[styles.line, state]}>
|
||||
{l.text || '♪'}
|
||||
</Animated.Text>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { gap: space.sm },
|
||||
note: { color: colors.muted, fontSize: 13 },
|
||||
line: { color: colors.text, fontSize: 15, lineHeight: 22 },
|
||||
plain: { opacity: 0.9 },
|
||||
past: { opacity: 0.4 },
|
||||
now: { fontFamily: fonts.display, fontSize: 17, lineHeight: 24 },
|
||||
next: { opacity: 0.65 },
|
||||
footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
toggle: { color: colors.accent2, fontSize: 13, fontWeight: '600' },
|
||||
source: { color: colors.muted, fontSize: 11, marginLeft: 'auto' },
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -17,9 +17,10 @@ import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Equalizer } from './Equalizer';
|
||||
import { Lyrics } from './Lyrics';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Reactions } from './Reactions';
|
||||
import { TrackCard } from './TrackCard';
|
||||
import { ServiceLinks, TrackRow } from './TrackCard';
|
||||
|
||||
export function NowPlayingCard({
|
||||
np,
|
||||
@@ -42,6 +43,11 @@ export function NowPlayingCard({
|
||||
wasPinned.current = pinned;
|
||||
}, [pinned, star]);
|
||||
const starStyle = useAnimatedStyle(() => ({ transform: [{ scale: star.value }] }));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const toggle = () => {
|
||||
tapHaptic();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
const togglePin = () => {
|
||||
if (pinned) tapHaptic();
|
||||
@@ -62,7 +68,7 @@ export function NowPlayingCard({
|
||||
<View style={styles.header}>
|
||||
{showUser && (
|
||||
<Pressable style={styles.user} onPress={() => router.push(`/u/${np.user.username}`)} accessibilityRole="link">
|
||||
<Avatar user={np.user} size={30} live={np.isPlaying} />
|
||||
<Avatar user={np.user} size={26} live={np.isPlaying} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{np.user.displayName ?? np.user.username}
|
||||
</Text>
|
||||
@@ -93,10 +99,16 @@ export function NowPlayingCard({
|
||||
</View>
|
||||
|
||||
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
|
||||
<TrackCard track={np.track} preferred={preferred} />
|
||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
|
||||
</Animated.View>
|
||||
<ProgressBar np={np} />
|
||||
<ProgressBar np={np} compact={!expanded} />
|
||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
||||
{expanded && (
|
||||
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
|
||||
<ServiceLinks track={np.track} preferred={preferred} />
|
||||
<Lyrics track={np.track} np={np} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -104,13 +116,14 @@ export function NowPlayingCard({
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: radius.lg,
|
||||
padding: space.lg,
|
||||
gap: space.md,
|
||||
padding: space.md + 2,
|
||||
gap: space.sm + 2,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
details: { gap: space.md },
|
||||
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 },
|
||||
|
||||
@@ -6,13 +6,13 @@ import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshar
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
|
||||
export function ProgressBar({ np }: { np: NowPlayingDTO }) {
|
||||
export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) {
|
||||
if (!np.isPlaying || playbackPosition(np, 0) === null) return null;
|
||||
// Each fresh position from the server restarts the clock, realigning the bar.
|
||||
return <Bar key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} np={np} />;
|
||||
return <Bar key={`${np.track.id}|${np.progressMs}|${np.updatedAt}`} np={np} compact={compact} />;
|
||||
}
|
||||
|
||||
function Bar({ np }: { np: NowPlayingDTO }) {
|
||||
function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
||||
const [receivedAt] = useState(() => Date.now());
|
||||
const [now, setNow] = useState(receivedAt);
|
||||
useEffect(() => {
|
||||
@@ -36,16 +36,18 @@ function Bar({ np }: { np: NowPlayingDTO }) {
|
||||
<LinearGradient colors={['#7b2ff7', '#2f8cff']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={StyleSheet.absoluteFill} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
<View style={styles.times}>
|
||||
<Text style={styles.time}>{formatDuration(pos.positionMs)}</Text>
|
||||
<Text style={styles.time}>{formatDuration(pos.durationMs)}</Text>
|
||||
</View>
|
||||
{!compact && (
|
||||
<View style={styles.times}>
|
||||
<Text style={styles.time}>{formatDuration(pos.positionMs)}</Text>
|
||||
<Text style={styles.time}>{formatDuration(pos.durationMs)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { marginTop: 12 },
|
||||
wrap: { marginTop: 2 },
|
||||
track: { height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.14)', overflow: 'hidden' },
|
||||
fill: { height: '100%', borderRadius: 2, overflow: 'hidden' },
|
||||
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||
|
||||
@@ -20,50 +20,114 @@ function shareTrack(track: TrackDTO, from: string | undefined) {
|
||||
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 = viewerServices(me).filter((s) => s !== preferred);
|
||||
function Artwork({ track, size }: { track: TrackDTO; size: number }) {
|
||||
const box = { width: size, height: size, borderRadius: size > 64 ? 14 : 10 };
|
||||
return track.artworkUrl ? (
|
||||
<Image source={track.artworkUrl} style={[styles.artwork, box]} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[styles.artwork, styles.artworkEmpty, box]}>
|
||||
<Text style={[styles.note, { fontSize: size / 3 }]}>♪</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The big "Open in Spotify" button for your main service ("Search in" when we have no direct link). */
|
||||
function PrimaryButton({ track, preferred, label }: { track: TrackDTO; preferred: StreamingService; label?: 'short' }) {
|
||||
const { url, exact } = openUrl(track, preferred);
|
||||
const name = SERVICE_LABELS[preferred];
|
||||
return (
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title={label === 'short' ? (exact ? 'Open ▶' : 'Search') : `${exact ? 'Open' : 'Search'} in ${name}`}
|
||||
accessibilityLabel={`${exact ? 'Open' : 'Search'} in ${name}`}
|
||||
onPress={() => open(url)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Quieter links for your other services, and Share. */
|
||||
export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const { me } = useSession();
|
||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One slim line: small artwork, title and artist, the open button, and an
|
||||
* arrow that shows the rest (`expanded` / `onToggle`).
|
||||
*/
|
||||
export function TrackRow({
|
||||
track,
|
||||
preferred,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
track: TrackDTO;
|
||||
preferred: StreamingService | null | undefined;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable style={styles.rowMain} onPress={onToggle} accessibilityRole="button" accessibilityState={{ expanded }}>
|
||||
<Artwork track={track} size={52} />
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text style={styles.artist} numberOfLines={1}>
|
||||
{track.artist}
|
||||
{expanded && track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{preferred && <PrimaryButton track={track} preferred={preferred} label="short" />}
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={expanded ? 'Show less' : 'Show more'}
|
||||
style={styles.chevron}>
|
||||
<Text style={[styles.chevronText, expanded && { transform: [{ rotate: '180deg' }] }]}>⌄</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** A song with big artwork and every link showing (e.g. a profile's featured song). */
|
||||
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
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>
|
||||
)}
|
||||
<Artwork track={track} size={96} />
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
<Text style={[styles.title, styles.titleBig]} 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}
|
||||
/>
|
||||
{preferred && (
|
||||
<View style={styles.primary}>
|
||||
<PrimaryButton track={track} preferred={preferred} />
|
||||
</View>
|
||||
)}
|
||||
<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>
|
||||
<ServiceLinks track={track} preferred={preferred} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -71,20 +135,22 @@ export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: St
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: { flexDirection: 'row', gap: space.md + 2, alignItems: 'flex-start' },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||
rowMain: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||
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 },
|
||||
note: { color: 'rgba(255,255,255,0.85)' },
|
||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 18, letterSpacing: -0.2 },
|
||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 16, letterSpacing: -0.2 },
|
||||
titleBig: { fontSize: 18 },
|
||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
muted: { color: colors.muted },
|
||||
chevron: { paddingHorizontal: space.xs, alignSelf: 'stretch', justifyContent: 'center' },
|
||||
chevronText: { color: colors.muted, fontSize: 22, lineHeight: 24 },
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user