jevstrudel.git / website / src / jev / Lyrics.jsx
Lyrics.jsx75 lines · 3.2 KB · raw
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}