import { useEffect, useMemo, useRef } from 'react'; import { Loader2, Music4 } from 'lucide-react'; import type { LyricLine } from './lyrics'; import { seekPlayer } from 'officerdev'; import { useActiveLyricIndex } from './useLyrics'; type LyricsPaneProps = { lines: LyricLine[] | null; synced: boolean; loading: boolean; }; /** * The lyrics sheet. Synced (.lrc) lyrics centre, highlight the current line, auto-scroll and seek on * click; plain (.txt) lyrics left-align and scroll by hand only. * * It takes no position prop: it subscribes to the player clock itself and only re-renders when the * highlight moves, so the sixty-frames-a-second feed never reaches the DOM. */ export const LyricsPane = ({ lines, synced, loading }: LyricsPaneProps) => { const scrollRef = useRef(null); const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]); const activeIndex = useActiveLyricIndex(lines, synced); // Keep the active line ~40% down the panel. scrollTop rather than scrollIntoView, which would also // scroll every ancestor and drag the whole workspace. useEffect(() => { if (!synced || activeIndex < 0) return; const box = scrollRef.current; const el = lineRefs.current[activeIndex]; if (!box || !el) return; box.scrollTo({ top: Math.max(0, el.offsetTop - box.clientHeight * 0.4), behavior: 'smooth' }); }, [activeIndex, synced]); const rendered = useMemo(() => { if (!lines) return null; return lines.map((line, i) => { const active = synced && i === activeIndex; const seekable = synced && line.timeSec != null; return (

{ lineRefs.current[i] = el; }} onClick={seekable ? () => seekPlayer(line.timeSec!) : undefined} className={[ 'py-1 text-[15px] font-semibold leading-7 transition-colors duration-200', synced ? 'text-center' : 'text-left text-foreground/85', // Only the colour changes on the active line — no weight or size change, so nothing reflows // and the sheet does not jitter as the highlight moves. active ? 'text-foreground' : synced ? 'text-muted-foreground' : '', seekable ? 'cursor-pointer hover:text-foreground/80' : '', ] .filter(Boolean) .join(' ')} > {line.text || (synced ? '♪' : ' ')}

); }); }, [lines, synced, activeIndex]); const empty = !loading && (!lines || !lines.length); return (
{loading && (
)} {empty && (

No lyrics for this track.

)} {!loading &&
{rendered}
} {/* Tail so the last lines can still scroll up to the 40% mark. */} {!loading && synced &&
}
); };