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>
85 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
};
|