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(`/tracks/${track.id}/lyrics`); const [full, setFull] = useState(false); if (isLoading) return Finding lyrics…; if (error) return Couldn't load lyrics right now.; if (!data) return No lyrics found for this song.; if (data.instrumental && !data.synced && !data.plain) return ♪ Instrumental; 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 ( 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. ) : ( (clipped ? all.slice(0, PREVIEW) : all).map((t, i) => ( {t} )) )} {canToggle && ( setFull((v) => !v)}> {!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'} )} Linking.openURL(data.source.url)}> Lyrics from {data.source.name} ); } /** 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 ( {l.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' }, });