Files
musicshare/apps/mobile/src/components/Lyrics.tsx
T
chrisandClaude Opus 5.5 68ec5a8d99 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>
2026-10-14 21:19:56 -04:00

98 lines
4.2 KiB
TypeScript

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&apos;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' },
});