Files
musicshare/apps/mobile/src/components/Lyrics.tsx
T
chrisandClaude Opus 5.5 864ab88627 New look: slate-indigo panels, pink accent, Poppins, and a gradient card for your song
Inspired by a dashboard design the user shared.
- App: new palette and fonts, borderless rounded panels with soft shadows, a pink-to-blue
  gradient card for your own song (text and controls switch to white on it), flat cards for
  friends, a 'Hi Chris, welcome back' header, an outline bell, and a pink pill on the active tab.
- Website: the same look, plus a wide-screen layout with a sidebar, the page in a rounded panel,
  and a right column with your profile, recent activity and recent plays. Phones keep the top
  bar and bottom tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 15:01:20 -04:00

100 lines
4.4 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, useInk } 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 ink = useInk();
const note = [styles.note, { color: ink.muted }];
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`);
const [full, setFull] = useState(false);
if (isLoading) return <Text style={note}>Finding lyrics…</Text>;
if (error) return <Text style={note}>Couldn&apos;t load lyrics right now.</Text>;
if (!data) return <Text style={note}>No lyrics found for this song.</Text>;
if (data.instrumental && !data.synced && !data.plain) return <Text style={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, ink.hero && { color: '#ffffff' }]} onPress={() => setFull((v) => !v)}>
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
</Text>
)}
<Text style={[styles.source, { color: ink.muted }]} 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, fontFamily: fonts.body },
line: { color: colors.text, fontSize: 14.5, lineHeight: 22, fontFamily: fonts.body },
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.accent, fontSize: 13, fontFamily: fonts.bodyMedium },
source: { color: colors.muted, fontSize: 11, fontFamily: fonts.body, marginLeft: 'auto' },
});