diff --git a/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx b/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx
index 510c7d28..a8d68c63 100644
--- a/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx
+++ b/src/workspaces/officerdev/src/MusicPlayer/LyricsPane.tsx
@@ -1,35 +1,29 @@
import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics';
-import { activeLineIndex } from './lyrics';
+import { seekPlayer } from './player-time';
+import { useActiveLyricIndex } from './useLyrics';
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 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.
*
- * 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.
+ * 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, positionSec, onSeek }: LyricsPaneProps) => {
+export const LyricsPane = ({ lines, synced, loading }: LyricsPaneProps) => {
const scrollRef = useRef(null);
const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]);
+ const activeIndex = useActiveLyricIndex(lines, synced);
- 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.
+ // 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;
@@ -49,7 +43,7 @@ export const LyricsPane = ({ lines, synced, loading, positionSec, onSeek }: Lyri
ref={(el) => {
lineRefs.current[i] = el;
}}
- onClick={seekable ? () => onSeek(line.timeSec!) : undefined}
+ 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',
@@ -61,21 +55,16 @@ export const LyricsPane = ({ lines, synced, loading, positionSec, onSeek }: Lyri
.filter(Boolean)
.join(' ')}
>
- {line.text || (synced ? '♪' : ' ')}
+ {line.text || (synced ? '♪' : ' ')}
}
+ {/* Tail so the last lines can still scroll up to the 40% mark. */}
+ {!loading && synced && }
);
};
diff --git a/src/workspaces/officerdev/src/MusicPlayer/LyricsPanel.tsx b/src/workspaces/officerdev/src/MusicPlayer/LyricsPanel.tsx
new file mode 100644
index 00000000..2c3a514f
--- /dev/null
+++ b/src/workspaces/officerdev/src/MusicPlayer/LyricsPanel.tsx
@@ -0,0 +1,46 @@
+import { useClient } from 'hooks/useClient';
+import { MicVocal } from 'lucide-react';
+import { LyricsPane } from './LyricsPane';
+import { useLyrics } from './useLyrics';
+import { useLyricsOpen } from './useLyricsOpen';
+import { useMusicPlayer } from './useMusicPlayer';
+
+/**
+ * The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
+ * the browsed album — which is why it reads the player rather than taking props from the album view.
+ */
+export const LyricsPanel = () => {
+ const { token } = useClient();
+ const { current } = useMusicPlayer();
+ const [, toggleLyrics] = useLyricsOpen();
+ const lyrics = useLyrics(current?.albumRel ?? '', current?.file ?? '', true, token);
+
+ return (
+
+
+
+
+
{current?.title ?? current?.file ?? 'Lyrics'}
+ {current?.artist &&
{current.artist}
}
+
+
+
+
+ {current ? (
+
+ ) : (
+
+ Play something to see its lyrics.
+
+ )}
+
+
+ );
+};
diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx
index d42f8f65..051d549f 100644
--- a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx
+++ b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx
@@ -15,8 +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';
+import { publishPlayerTime, registerPlayerSeek } from './player-time';
+import { useLyricsOpen } from './useLyricsOpen';
// Mounted once in the persistent DashboardLayout (outside ), so it owns the single audio engine
// and the site-wide play dock — playback survives navigation between routes.
@@ -47,10 +47,9 @@ export const MusicPlayerHost = () => {
return Number.isFinite(v) ? v : 1;
});
const [muted, setMuted] = useState(false);
- const [lyricsOpen, setLyricsOpen] = useState(() => localStorage.getItem('music.lyrics') === '1');
+ const [lyricsOpen, toggleLyrics] = useLyricsOpen();
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
@@ -101,6 +100,7 @@ export const MusicPlayerHost = () => {
durationRef.current = dur;
setPosition(pos);
setDuration(dur);
+ publishPlayerTime(pos); // the lyrics panel lives in another tree — see player-time.ts
isRestoringRef.current = false; // saved position has been applied — safe to persist again
},
onIndex: (i) => {
@@ -215,6 +215,16 @@ export const MusicPlayerHost = () => {
localStorage.setItem('music.volume', String(v));
};
+ // Seek is the engine's, and the engine is this component's — so the lyrics panel, which lives in the
+ // /music workspace rather than under the dock, reaches it through this registration.
+ const seekTo = useCallback((sec: number) => {
+ setPosition(sec);
+ publishPlayerTime(sec);
+ engineRef.current?.seek(sec);
+ }, []);
+
+ useEffect(() => registerPlayerSeek(seekTo), [seekTo]);
+
// Scrubber → engine.seek (Web Audio has no