1// Lyrics on screen (lyrics.mjs): the line being sung, over the bottom of the 2// code, each syllable lighting as it is sung and a ball hopping from one to 3// the next, landing on each as it starts. 4import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; 5import { lineMs, lyricsStore, position } from './lyrics.mjs'; 6import { themeColor as ACCENT } from './site.mjs'; 7 8const HOP = 22; // px the ball rises between two syllables 9 10export function Lyrics() { 11 const line = useSyncExternalStore(lyricsStore.subscribe, lyricsStore.get, () => null); 12 const row = useRef(null); 13 const ball = useRef(null); 14 const centres = useRef([]); 15 16 // where each syllable sits, measured once the line is laid out 17 useLayoutEffect(() => { 18 if (!line || !row.current) return; 19 const box = row.current.getBoundingClientRect(); 20 centres.current = [...row.current.querySelectorAll('[data-syllable]')].map((el) => { 21 const r = el.getBoundingClientRect(); 22 return r.left - box.left + (r.width - (el.dataset.space ? r.width * 0.2 : 0)) / 2; 23 }); 24 }, [line]); 25 26 useEffect(() => { 27 if (!line) return; 28 let frame; 29 const draw = () => { 30 const { index, progress } = position(line.lyric, lineMs(line)); 31 const spans = row.current?.querySelectorAll('[data-syllable]') ?? []; 32 spans.forEach((el, i) => { 33 el.style.color = i <= index ? ACCENT : ''; 34 el.style.opacity = i <= index ? '1' : '.75'; 35 }); 36 const xs = centres.current; 37 if (ball.current && xs.length) { 38 const at = Math.max(0, index); 39 const from = xs[at]; 40 const to = xs[Math.min(at + 1, xs.length - 1)]; 41 // before the first syllable the ball waits on it; after the last it rests 42 const p = index < 0 || at === xs.length - 1 ? 0 : progress; 43 const lift = index < 0 ? 0 : Math.sin(Math.PI * (at === xs.length - 1 ? Math.min(1, progress * 2) : p)) * HOP; 44 ball.current.style.transform = `translate(${from + (to - from) * p - 6}px, ${-lift}px)`; 45 } 46 frame = requestAnimationFrame(draw); 47 }; 48 frame = requestAnimationFrame(draw); 49 return () => cancelAnimationFrame(frame); 50 }, [line]); 51 52 if (!line) return null; 53 return ( 54 // a row of no height between the code and the pane under it: the words overlay the code's last lines 55 <div className="relative h-0 z-30 pointer-events-none" data-testid="jev-lyrics"> 56 <div className="absolute bottom-3 inset-x-0 flex justify-center px-4"> 57 <div className="relative rounded-lg bg-black/90 backdrop-blur-sm px-5 pb-2 pt-9 text-center shadow-lg max-w-full" 58 style={{ border: `1px solid ${ACCENT}55` }}> 59 <div 60 ref={ball} 61 className="absolute left-5 top-5 h-3 w-3 rounded-full" 62 style={{ background: ACCENT, boxShadow: `0 0 8px ${ACCENT}` }} 63 /> 64 <div ref={row} className="relative whitespace-pre-wrap text-xl sm:text-2xl font-bold text-white leading-snug"> 65 {line.lyric.syllables.map(([text], i) => ( 66 <span key={`${line.key}-${i}`} data-syllable data-space={text.endsWith(' ') ? '1' : undefined}> 67 {text} 68 </span> 69 ))} 70 </div> 71 </div> 72 </div> 73 </div> 74 ); 75}