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 ? '♪' : ' ')}

); }); - }, [lines, synced, activeIndex, onSeek]); + }, [lines, synced, activeIndex]); const empty = !loading && (!lines || !lines.length); return ( -
+
{loading && (
@@ -88,6 +77,8 @@ export const LyricsPane = ({ lines, synced, loading, positionSec, onSeek }: Lyri
)} {!loading &&
{rendered}
} + {/* 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
@@ -360,4 +403,12 @@ export const MusicDetail = () => { )}
); + + if (!lyricsOpen) return libraryView; + + return ( + + + + ); };