import { useEffect, useState } from 'react'; import type { LyricLine } from './lyrics'; import { activeLineIndex, parseLyrics } from './lyrics'; import { getPlayerTime, subscribePlayerTime } from 'officerdev'; export type UseLyrics = { loading: boolean; /** null while loading, and when the track has none. */ lines: LyricLine[] | null; synced: boolean; }; /** * Fetch + parse the current track's lyrics. Gated on `enabled` so nothing is requested until the pane * is actually open — the dock lives on every screen and most listening happens with it closed. * * Deliberately NOT gated on an index "has lyrics" flag the way the mobile app does: the web player's * queue carries only what it needs to stream, and a 404 for a track without lyrics is cheaper than * threading that flag through every producer of a queue. * * Auth goes in the query string rather than a header, matching how this component already builds its * /stream and /cover URLs. */ export const useLyrics = (albumRel: string, file: string, enabled: boolean, token: string | null): UseLyrics => { const [state, setState] = useState({ loading: false, lines: null, synced: false }); useEffect(() => { if (!enabled || !albumRel || !file) { setState({ loading: false, lines: null, synced: false }); return; } const url = `/api/music/lyrics?path=${encodeURIComponent(albumRel)}&file=${encodeURIComponent(file)}` + (token ? `&token=${encodeURIComponent(token)}` : ''); const ctrl = new AbortController(); setState({ loading: true, lines: null, synced: false }); fetch(url, { signal: ctrl.signal }) .then(async (res) => { // 404 is the ordinary "this track has no lyrics" answer, not an error worth surfacing. if (!res.ok) return setState({ loading: false, lines: null, synced: false }); const { synced, lines } = parseLyrics(await res.text()); setState({ loading: false, lines, synced }); }) .catch(() => { if (!ctrl.signal.aborted) setState({ loading: false, lines: null, synced: false }); }); return () => ctrl.abort(); }, [albumRel, file, enabled, token]); return state; }; /** * Index of the line to highlight, driven by the engine's position feed. * * The feed ticks every animation frame; this re-renders only when the index actually moves — React bails * out of an identical setState — so a synced sheet repaints about once a line instead of sixty times a * second, even though it lives nowhere near the component that owns the clock. */ export const useActiveLyricIndex = (lines: LyricLine[] | null, synced: boolean): number => { const [index, setIndex] = useState(-1); useEffect(() => { if (!synced || !lines) { setIndex(-1); return; } setIndex(activeLineIndex(lines, getPlayerTime())); return subscribePlayerTime((sec) => { const next = activeLineIndex(lines, sec); setIndex((prev) => (prev === next ? prev : next)); }); }, [lines, synced]); return index; };