From 2c136c4b930b6d2419a50aacff757f62312fa852 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Padez?=
Date: Tue, 4 Aug 2026 15:22:13 +0000
Subject: [PATCH] lyrics render in a split of the music detail panel, not a
dock sheet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the microphone is the same switch in two places — the play dock and the album
header — so its state moves to a channel seeded from localStorage. turning it
on splits the /music detail panel in two with a nested WorkspaceLayout: a fixed
layout, components keyed by panel id, no persistence and no registry entries.
the album view is handed to the left panel through context, so the split moves
the same element instead of remounting it and refetching the album.
playback position now reaches the pane through a module-level publisher rather
than props — it lives outside the player's subtree, and the feed ticks every
animation frame. the pane subscribes and re-renders only when the active line
changes, about once a line.
Co-Authored-By: Claude Opus 5
---
.../officerdev/src/MusicPlayer/LyricsPane.tsx | 41 ++-
.../src/MusicPlayer/LyricsPanel.tsx | 46 +++
.../src/MusicPlayer/MusicPlayerHost.tsx | 269 +++++++++---------
.../officerdev/src/MusicPlayer/player-time.ts | 39 +++
.../officerdev/src/MusicPlayer/useLyrics.ts | 28 +-
.../src/MusicPlayer/useLyricsOpen.ts | 25 ++
.../officerdev/src/apps/Music/MusicDetail.tsx | 61 +++-
7 files changed, 338 insertions(+), 171 deletions(-)
create mode 100644 src/workspaces/officerdev/src/MusicPlayer/LyricsPanel.tsx
create mode 100644 src/workspaces/officerdev/src/MusicPlayer/player-time.ts
create mode 100644 src/workspaces/officerdev/src/MusicPlayer/useLyricsOpen.ts
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