Files
music/web/LyricsPane.tsx
T
Claude Opus 5 6e07da7a36 music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 —
41 files, unchanged from the tree they left.

  manifest.ts   identity, one permission, ffmpeg/ffprobe declared
  api/          the sidecar proxy; the prefix comes from mountPrefix()
  sidecar/      the whole /api/music contract — indexing, streaming, per-user state
  db/           music_favorites, _playlists, _playlist_items, _now_playing
  web/          panels, layout, and the player: engine, bar, lyrics, favourites
  cliamp/       the second playback path, parked — not working, kept deliberately
  widgets/      the dashboard widget, parked — plugins cannot contribute widgets
  assets/       icon.png, the dock tile
  scripts/      the reindex CLI

PLUGIN.md is the design record: what moved, what stayed, what broke, and why.
MUSIC_API.md is the contract the phone and tablet apps speak, and the reason
the sidecar's HTTP shape is not free to change.

── It does not build here, and that is the point ──

The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*`
through the workspace links in its own node_modules. Measured from this
directory, outside the platform checkout, every one of them fails to resolve —
7 imports in the backend, ~29 in the frontend.

So this repository is the source of truth, not yet a buildable unit. Making it
one means the host API becoming something a plugin can depend on rather than
something it reaches into. That is the next problem, and having the code here
is what makes it unavoidable rather than theoretical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:34:51 +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>
);
};