lyrics sheet in the music dock
Ports the mobile app's lyric parser (packages/core/src/services/lyrics.ts) to the web player and expands a sheet above the play dock. Synced .lrc lines highlight, auto-scroll and seek on click; plain .txt scrolls by hand. The server side already served all of this — /api/music/lyrics and the indexer's embedded-USLT extraction — so nothing changed behind the proxy. The sheet lives in the dock rather than a /music panel because the dock is mounted app-wide: lyrics follow the music onto every screen, and no saved workspace layout has to be migrated to see it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Loader2, Music4 } from 'lucide-react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { activeLineIndex } from './lyrics';
|
||||
|
||||
type LyricsPaneProps = {
|
||||
lines: LyricLine[] | null;
|
||||
synced: boolean;
|
||||
loading: boolean;
|
||||
positionSec: number;
|
||||
onSeek: (sec: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The lyrics sheet that expands above the play dock. Synced (.lrc) lyrics centre, highlight the current
|
||||
* line, auto-scroll and seek on click; plain (.txt) lyrics left-align and scroll by hand only.
|
||||
*
|
||||
* The host re-renders every animation frame (it drives the scrubber), so the line list is memoised on
|
||||
* the ACTIVE INDEX rather than the position — the DOM is rebuilt when the highlight moves, roughly once
|
||||
* a line, not sixty times a second.
|
||||
*/
|
||||
export const LyricsPane = ({ lines, synced, loading, positionSec, onSeek }: LyricsPaneProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]);
|
||||
|
||||
const activeIndex = useMemo(
|
||||
() => (synced && lines ? activeLineIndex(lines, positionSec) : -1),
|
||||
[synced, lines, positionSec],
|
||||
);
|
||||
|
||||
// Keep the active line ~40% down the viewport. scrollTop rather than scrollIntoView, which would also
|
||||
// scroll every ancestor and drag the whole page when the dock sits at the bottom of a scrolled screen.
|
||||
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 (
|
||||
<p
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
lineRefs.current[i] = el;
|
||||
}}
|
||||
onClick={seekable ? () => onSeek(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/50' : '',
|
||||
seekable ? 'cursor-pointer hover:text-foreground/80' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{line.text || (synced ? '♪' : ' ')}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}, [lines, synced, activeIndex, onSeek]);
|
||||
|
||||
const empty = !loading && (!lines || !lines.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[38vh] overflow-y-auto border-t border-border bg-card/95 px-6 backdrop-blur"
|
||||
// Synced lyrics keep a tail of padding so the last lines can still scroll up to the 40% mark.
|
||||
style={{ paddingBottom: synced ? '22vh' : '1rem', paddingTop: '0.75rem' }}
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Music4 size={28} className="opacity-40" />
|
||||
<p className="text-sm">No lyrics for this track.</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && <div className="mx-auto max-w-2xl">{rendered}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2 } from 'lucide-react';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
||||
import {
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '../apps/Music/shared';
|
||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||
import { LyricsPane } from './LyricsPane';
|
||||
import { useLyrics } from './useLyrics';
|
||||
|
||||
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single audio engine
|
||||
// and the site-wide play dock — playback survives navigation between routes.
|
||||
@@ -45,8 +47,10 @@ export const MusicPlayerHost = () => {
|
||||
return Number.isFinite(v) ? v : 1;
|
||||
});
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [lyricsOpen, setLyricsOpen] = useState(() => localStorage.getItem('music.lyrics') === '1');
|
||||
|
||||
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
|
||||
const lyrics = useLyrics(current?.albumRel ?? '', current?.file ?? '', lyricsOpen, token);
|
||||
|
||||
// Latest position/duration in refs so persist reads fresh values without re-subscribing. seekToRef holds
|
||||
// a pending restore offset; isRestoringRef suppresses persist during the restore load so it doesn't
|
||||
@@ -234,6 +238,20 @@ export const MusicPlayerHost = () => {
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// Clicking a synced lyric line seeks to it. Stable identity: LyricsPane memoises its line list on this
|
||||
// callback, and the host re-renders every animation frame.
|
||||
const seekTo = useCallback((sec: number) => {
|
||||
setPosition(sec);
|
||||
engineRef.current?.seek(sec);
|
||||
}, []);
|
||||
|
||||
const toggleLyrics = () => {
|
||||
setLyricsOpen((open) => {
|
||||
localStorage.setItem('music.lyrics', open ? '0' : '1');
|
||||
return !open;
|
||||
});
|
||||
};
|
||||
|
||||
const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? '';
|
||||
const pct = duration ? (position / duration) * 100 : 0;
|
||||
|
||||
@@ -254,117 +272,139 @@ export const MusicPlayerHost = () => {
|
||||
if (!current) return null;
|
||||
|
||||
// In-flow bottom bar (NOT position:fixed) — it reserves its own height so the content above shrinks to
|
||||
// fit and the nav dock naturally sits above it, no overlap hacks needed.
|
||||
// fit and the nav dock naturally sits above it, no overlap hacks needed. The lyrics sheet is part of
|
||||
// that flow too, so opening it shrinks the page rather than covering it.
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||
{/* cover + info — click to open this album in /music */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCurrentAlbum}
|
||||
title="Show in library"
|
||||
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
src={coverUrl(current.albumRel)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
<div className="flex shrink-0 flex-col">
|
||||
{lyricsOpen && (
|
||||
<LyricsPane
|
||||
lines={lyrics.lines}
|
||||
synced={lyrics.synced}
|
||||
loading={lyrics.loading}
|
||||
positionSec={position}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||
{/* cover + info — click to open this album in /music */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCurrentAlbum}
|
||||
title="Show in library"
|
||||
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
src={coverUrl(current.albumRel)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-44 shrink-0 sm:block">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause size={18} />
|
||||
) : (
|
||||
<Play size={18} className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= queue.length - 1}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* scrubber + times */}
|
||||
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(position)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={pct}
|
||||
trackClass="bg-muted"
|
||||
fillClass="bg-primary"
|
||||
thumbClass="border-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-44 shrink-0 sm:block">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
||||
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(duration)}
|
||||
</span>
|
||||
|
||||
{/* volume */}
|
||||
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="h-1 w-16 cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
onClick={toggleLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`shrink-0 cursor-pointer p-1.5 hover:text-foreground ${lyricsOpen ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
<MicVocal size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause size={18} />
|
||||
) : (
|
||||
<Play size={18} className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= queue.length - 1}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* scrubber + times */}
|
||||
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(position)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={pct}
|
||||
trackClass="bg-muted"
|
||||
fillClass="bg-primary"
|
||||
thumbClass="border-background"
|
||||
<MusicHeart
|
||||
kind="track"
|
||||
favKey={trackHomePath(current.albumRel, current.file)}
|
||||
size={18}
|
||||
className="shrink-0 p-1.5"
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(duration)}
|
||||
</span>
|
||||
|
||||
{/* volume */}
|
||||
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
<X size={16} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="h-1 w-16 cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MusicHeart
|
||||
kind="track"
|
||||
favKey={trackHomePath(current.albumRel, current.file)}
|
||||
size={18}
|
||||
className="shrink-0 p-1.5"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseLyrics, activeLineIndex } from './lyrics';
|
||||
|
||||
describe('parseLyrics', () => {
|
||||
test('plain text is not synced and keeps every line, blanks included', () => {
|
||||
const { synced, lines } = parseLyrics('first\n\n second \n');
|
||||
expect(synced).toBe(false);
|
||||
expect(lines.map((l) => l.text)).toEqual(['first', '', 'second', '']);
|
||||
expect(lines.every((l) => l.timeSec === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('lrc timestamps parse to seconds, with hundredths', () => {
|
||||
const { synced, lines } = parseLyrics('[00:12.50]hello\n[01:03]world');
|
||||
expect(synced).toBe(true);
|
||||
expect(lines).toEqual([
|
||||
{ timeSec: 12.5, text: 'hello' },
|
||||
{ timeSec: 63, text: 'world' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a single-digit fraction is tenths, not thousandths', () => {
|
||||
expect(parseLyrics('[00:01.5]x').lines[0]?.timeSec).toBe(1.5);
|
||||
});
|
||||
|
||||
test('metadata tags are dropped', () => {
|
||||
const { lines } = parseLyrics('[ar:Artist]\n[ti:Title]\n[00:01.00]real');
|
||||
expect(lines).toEqual([{ timeSec: 1, text: 'real' }]);
|
||||
});
|
||||
|
||||
test('several stamps on one line become several lines, sorted by time', () => {
|
||||
const { lines } = parseLyrics('[02:00.00][00:30.00]chorus\n[01:00.00]verse');
|
||||
expect(lines).toEqual([
|
||||
{ timeSec: 30, text: 'chorus' },
|
||||
{ timeSec: 60, text: 'verse' },
|
||||
{ timeSec: 120, text: 'chorus' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('an untimed line inside a synced file survives, but blanks do not', () => {
|
||||
const { lines } = parseLyrics('[00:01.00]a\n\nspoken\n');
|
||||
expect(lines.map((l) => l.text)).toEqual(['spoken', 'a']);
|
||||
});
|
||||
|
||||
test('an empty timed line is kept — it is a musical rest', () => {
|
||||
expect(parseLyrics('[00:10.00]').lines).toEqual([{ timeSec: 10, text: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeLineIndex', () => {
|
||||
const lines = [
|
||||
{ timeSec: 10, text: 'a' },
|
||||
{ timeSec: 20, text: 'b' },
|
||||
{ timeSec: 30, text: 'c' },
|
||||
];
|
||||
|
||||
test('-1 before the first line', () => {
|
||||
expect(activeLineIndex(lines, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
test('the 0.2s lookahead highlights fractionally early', () => {
|
||||
expect(activeLineIndex(lines, 9.7)).toBe(-1);
|
||||
expect(activeLineIndex(lines, 9.9)).toBe(0);
|
||||
});
|
||||
|
||||
test('holds the last line past the end', () => {
|
||||
expect(activeLineIndex(lines, 25)).toBe(1);
|
||||
expect(activeLineIndex(lines, 9999)).toBe(2);
|
||||
});
|
||||
|
||||
test('untimed lines never become active', () => {
|
||||
expect(activeLineIndex([{ text: 'x' }, { timeSec: 5, text: 'y' }], 60)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Parse lyrics text into displayable lines. `.lrc` carries `[mm:ss.xx]` timestamps (possibly several per
|
||||
* line, e.g. repeated choruses) and metadata tags ([ar:], [ti:], …) which are dropped. `.txt` is plain.
|
||||
* A synced result is sorted by time so the active-line lookup is a simple scan.
|
||||
*
|
||||
* Ported from the mobile app (packages/core/src/services/lyrics.ts) — same file format, same server,
|
||||
* so the two must agree on what a line is.
|
||||
*/
|
||||
export type LyricLine = { timeSec?: number; text: string };
|
||||
export type ParsedLyrics = { synced: boolean; lines: LyricLine[] };
|
||||
|
||||
const TIME_RE = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
||||
const META_RE = /^\[(ar|ti|al|by|offset|length|re|ve|au|la|id):/i;
|
||||
|
||||
/**
|
||||
* Lines are treated as synced whenever the text actually contains `[mm:ss]` timestamps — the server's
|
||||
* `X-Lyrics-Format` header is not trusted (it need not survive a proxy, and embedded lyrics carrying
|
||||
* timestamps should sync regardless of which file they came from). No timestamps → plain text.
|
||||
*/
|
||||
export function parseLyrics(text: string): ParsedLyrics {
|
||||
if (!/\[\d{1,2}:\d{2}/.test(text)) {
|
||||
return { synced: false, lines: text.split(/\r?\n/).map((t) => ({ text: t.trim() })) };
|
||||
}
|
||||
|
||||
const out: LyricLine[] = [];
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
if (META_RE.test(rawLine.trim())) continue;
|
||||
const stamps: number[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
TIME_RE.lastIndex = 0;
|
||||
while ((m = TIME_RE.exec(rawLine)) !== null) {
|
||||
const min = Number(m[1]);
|
||||
const sec = Number(m[2]);
|
||||
// "[00:12.3]" is three tenths, not three milliseconds — pad right before reading as thousandths.
|
||||
const frac = m[3] ? Number(`${m[3]}00`.slice(0, 3)) / 1000 : 0;
|
||||
stamps.push(min * 60 + sec + frac);
|
||||
}
|
||||
const lyric = rawLine.replace(TIME_RE, '').trim();
|
||||
if (!stamps.length) {
|
||||
if (lyric) out.push({ text: lyric }); // a plain line inside an otherwise-synced file
|
||||
continue;
|
||||
}
|
||||
for (const t of stamps) out.push({ timeSec: t, text: lyric });
|
||||
}
|
||||
|
||||
const synced = out.some((l) => l.timeSec != null);
|
||||
if (synced) out.sort((a, b) => (a.timeSec ?? 0) - (b.timeSec ?? 0));
|
||||
return { synced, lines: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the active line for a playback position (synced only); -1 before the first line. The 0.2s
|
||||
* lookahead lands the highlight fractionally early, which reads as on-time — arriving late reads as lag.
|
||||
*/
|
||||
export function activeLineIndex(lines: LyricLine[], positionSec: number): number {
|
||||
let idx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i]?.timeSec;
|
||||
if (t == null) continue;
|
||||
if (t <= positionSec + 0.2) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { parseLyrics } from './lyrics';
|
||||
|
||||
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<UseLyrics>({ 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;
|
||||
};
|
||||
Reference in New Issue
Block a user