Files
platform/plugins/music/web/LyricsPane.tsx
T
pastilhas 0a55964db5 the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar,
favourites, lyrics toggle and the library vocabulary — ten files. The barrel
stops exporting a player it no longer has, and DashboardLayout stops rendering
one.

The reasoning that kept it was removed rather than refuted. It stayed because
the dashboard widget imported useMusicPlayer from officerdev and the platform
cannot import from a plugin, so the state had to stay whatever was decided
about the UI. The owner moved the widget into the plugin in the previous
commit, and the constraint went with it: the whole remaining dependency became
one line, DashboardLayout.tsx:66.

MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until
you notice it already returned null on /music — the mini bar is the transport
there, and the host existed purely to own the GaplessEngine. In the panel it
does exactly that, and the bar code stays intact for whenever there is a slot.

[phase 2] Leaving /music unmounts the host and playback stops. Deferred on the
owner's call; the bar was "navigating away must not break the application", and
that holds: seekPlayer is optional-chained so a call with no host registered is
a no-op, registerPlayerSeek clears only its own registration, the host's
cleanup destroys the engine and nulls its ref, and the queue is global state so
returning to /music remounts and reloads. Solving it properly needs either a
shell slot a plugin can contribute to — which reopens "there is no way to
export a component" — or the engine hoisted to module scope, which keeps the
rule and loses only the off-route controls.

Also: the parked widget now imports the player as a sibling rather than through
officerdev, and shared.ts stopped being a re-export shim now that the real file
is in the plugin.

Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts
/example /music /offscale, / and /music both 200, and the player is in the
built bundle (music.volume, music:lyrics, now-playing?device=web all present —
GaplessEngine is a class name and the production build is minified, so grepping
for it proves nothing).

Not verified by me: what it looks like in a browser. That needs your eyes.
2026-08-15 14:42:13 +00:00

85 lines
3.2 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics';
import { seekPlayer } from './player-time';
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<HTMLDivElement>(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 (
<p
key={i}
ref={(el) => {
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 ? '♪' : ' ')}
</p>
);
});
}, [lines, synced, activeIndex]);
const empty = !loading && (!lines || !lines.length);
return (
<div ref={scrollRef} className="h-full overflow-y-auto px-6 pt-3">
{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>}
{/* Tail so the last lines can still scroll up to the 40% mark. */}
{!loading && synced && <div style={{ height: '55%' }} />}
</div>
);
};