lyrics render in a split of the music detail panel, not a dock sheet
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLDivElement>(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 ? '♪' : ' ')}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}, [lines, synced, activeIndex, onSeek]);
|
||||
}, [lines, synced, activeIndex]);
|
||||
|
||||
const empty = !loading && (!lines || !lines.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[38vh] overflow-y-auto border-t border-border bg-card/95 px-6 backdrop-blur"
|
||||
// Synced lyrics keep a tail of padding so the last lines can still scroll up to the 40% mark.
|
||||
style={{ paddingBottom: synced ? '22vh' : '1rem', paddingTop: '0.75rem' }}
|
||||
>
|
||||
<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" />
|
||||
@@ -88,6 +77,8 @@ export const LyricsPane = ({ lines, synced, loading, positionSec, onSeek }: Lyri
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
|
||||
<MicVocal size={15} className="shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current?.title ?? current?.file ?? 'Lyrics'}</p>
|
||||
{current?.artist && <p className="truncate text-xs text-muted-foreground">{current.artist}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLyrics}
|
||||
title="Hide lyrics"
|
||||
className="shrink-0 cursor-pointer text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{current ? (
|
||||
<LyricsPane lines={lyrics.lines} synced={lyrics.synced} loading={lyrics.loading} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
Play something to see its lyrics.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 <Routes>), 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 <audio>.currentTime, so drive it directly).
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const onSeekDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -223,9 +233,7 @@ export const MusicPlayerHost = () => {
|
||||
if (!bar || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const sec = pct * duration;
|
||||
setPosition(sec);
|
||||
engineRef.current?.seek(sec);
|
||||
seekTo(pct * duration);
|
||||
};
|
||||
e.preventDefault();
|
||||
seekAt(e.clientX);
|
||||
@@ -238,20 +246,6 @@ export const MusicPlayerHost = () => {
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// Clicking a synced lyric line seeks to it. Stable identity: LyricsPane memoises its line list on this
|
||||
// callback, and the host re-renders every animation frame.
|
||||
const seekTo = useCallback((sec: number) => {
|
||||
setPosition(sec);
|
||||
engineRef.current?.seek(sec);
|
||||
}, []);
|
||||
|
||||
const toggleLyrics = () => {
|
||||
setLyricsOpen((open) => {
|
||||
localStorage.setItem('music.lyrics', open ? '0' : '1');
|
||||
return !open;
|
||||
});
|
||||
};
|
||||
|
||||
const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? '';
|
||||
const pct = duration ? (position / duration) * 100 : 0;
|
||||
|
||||
@@ -269,142 +263,137 @@ export const MusicPlayerHost = () => {
|
||||
navigate('/music');
|
||||
};
|
||||
|
||||
// The dock's microphone opens the lyrics panel inside the /music workspace, so it navigates there
|
||||
// rather than growing a sheet of its own — the dock keeps its height on every screen.
|
||||
const showLyrics = () => {
|
||||
if (!lyricsOpen) navigate('/music');
|
||||
toggleLyrics();
|
||||
};
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
// In-flow bottom bar (NOT position:fixed) — it reserves its own height so the content above shrinks to
|
||||
// fit and the nav dock naturally sits above it, no overlap hacks needed. The lyrics sheet is part of
|
||||
// that flow too, so opening it shrinks the page rather than covering it.
|
||||
// fit and the nav dock naturally sits above it, no overlap hacks needed.
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
{lyricsOpen && (
|
||||
<LyricsPane
|
||||
lines={lyrics.lines}
|
||||
synced={lyrics.synced}
|
||||
loading={lyrics.loading}
|
||||
positionSec={position}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||
{/* cover + info — click to open this album in /music */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCurrentAlbum}
|
||||
title="Show in library"
|
||||
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
src={coverUrl(current.albumRel)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-44 shrink-0 sm:block">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause size={18} />
|
||||
) : (
|
||||
<Play size={18} className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= queue.length - 1}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* scrubber + times */}
|
||||
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(position)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={pct}
|
||||
trackClass="bg-muted"
|
||||
fillClass="bg-primary"
|
||||
thumbClass="border-background"
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||
{/* cover + info — click to open this album in /music */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCurrentAlbum}
|
||||
title="Show in library"
|
||||
className="flex min-w-0 cursor-pointer items-center gap-3 rounded text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
<img
|
||||
src={coverUrl(current.albumRel)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(duration)}
|
||||
</span>
|
||||
|
||||
{/* volume */}
|
||||
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="h-1 w-16 cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="hidden w-44 shrink-0 sm:block">
|
||||
<p className="truncate text-sm font-medium text-foreground">{current.title ?? current.file}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`shrink-0 cursor-pointer p-1.5 hover:text-foreground ${lyricsOpen ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<MicVocal size={18} />
|
||||
<SkipBack size={18} />
|
||||
</button>
|
||||
|
||||
<MusicHeart
|
||||
kind="track"
|
||||
favKey={trackHomePath(current.albumRel, current.file)}
|
||||
size={18}
|
||||
className="shrink-0 p-1.5"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={toggle}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
|
||||
>
|
||||
<X size={16} />
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause size={18} />
|
||||
) : (
|
||||
<Play size={18} className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= queue.length - 1}
|
||||
className="cursor-pointer p-1.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* scrubber + times */}
|
||||
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(position)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SeekBar
|
||||
barRef={barRef}
|
||||
onSeekDown={onSeekDown}
|
||||
pct={pct}
|
||||
trackClass="bg-muted"
|
||||
fillClass="bg-primary"
|
||||
thumbClass="border-background"
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
|
||||
{fmt(duration)}
|
||||
</span>
|
||||
|
||||
{/* volume */}
|
||||
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
className="h-1 w-16 cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={showLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`shrink-0 cursor-pointer p-1.5 hover:text-foreground ${lyricsOpen ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
<MicVocal size={18} />
|
||||
</button>
|
||||
|
||||
<MusicHeart
|
||||
kind="track"
|
||||
favKey={trackHomePath(current.albumRel, current.file)}
|
||||
size={18}
|
||||
className="shrink-0 p-1.5"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Playback position, published outside React.
|
||||
*
|
||||
* The lyrics pane no longer lives in the player's subtree — it renders in a panel of the /music
|
||||
* workspace — so the position has to cross the tree. It cannot cross as state: the engine reports a new
|
||||
* position every animation frame, and a shared state channel would re-render every consumer 60× a
|
||||
* second. Instead the host pushes into this module and subscribers decide for themselves what is worth
|
||||
* a render (the lyrics pane only re-renders when the ACTIVE LINE changes, roughly once a line).
|
||||
*
|
||||
* Seek travels the other way for the same reason: the engine is the host's, but a click on a lyric line
|
||||
* has to reach it.
|
||||
*/
|
||||
let position = 0;
|
||||
const subscribers = new Set<(sec: number) => void>();
|
||||
let seekFn: ((sec: number) => void) | null = null;
|
||||
|
||||
export const publishPlayerTime = (sec: number): void => {
|
||||
position = sec;
|
||||
for (const fn of subscribers) fn(sec);
|
||||
};
|
||||
|
||||
/** Latest position, for a subscriber that mounts mid-track. */
|
||||
export const getPlayerTime = (): number => position;
|
||||
|
||||
export const subscribePlayerTime = (fn: (sec: number) => void): (() => void) => {
|
||||
subscribers.add(fn);
|
||||
return () => {
|
||||
subscribers.delete(fn);
|
||||
};
|
||||
};
|
||||
|
||||
export const registerPlayerSeek = (fn: (sec: number) => void): (() => void) => {
|
||||
seekFn = fn;
|
||||
return () => {
|
||||
if (seekFn === fn) seekFn = null;
|
||||
};
|
||||
};
|
||||
|
||||
export const seekPlayer = (sec: number): void => seekFn?.(sec);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LyricLine } from './lyrics';
|
||||
import { parseLyrics } from './lyrics';
|
||||
import { activeLineIndex, parseLyrics } from './lyrics';
|
||||
import { getPlayerTime, subscribePlayerTime } from './player-time';
|
||||
|
||||
export type UseLyrics = {
|
||||
loading: boolean;
|
||||
@@ -50,3 +51,28 @@ export const useLyrics = (albumRel: string, file: string, enabled: boolean, toke
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Index of the line to highlight, driven by the engine's position feed.
|
||||
*
|
||||
* The feed ticks every animation frame; this re-renders only when the index actually moves — React bails
|
||||
* out of an identical setState — so a synced sheet repaints about once a line instead of sixty times a
|
||||
* second, even though it lives nowhere near the component that owns the clock.
|
||||
*/
|
||||
export const useActiveLyricIndex = (lines: LyricLine[] | null, synced: boolean): number => {
|
||||
const [index, setIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!synced || !lines) {
|
||||
setIndex(-1);
|
||||
return;
|
||||
}
|
||||
setIndex(activeLineIndex(lines, getPlayerTime()));
|
||||
return subscribePlayerTime((sec) => {
|
||||
const next = activeLineIndex(lines, sec);
|
||||
setIndex((prev) => (prev === next ? prev : next));
|
||||
});
|
||||
}, [lines, synced]);
|
||||
|
||||
return index;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
|
||||
export const MUSIC_LYRICS_CHANNEL = 'music:lyrics';
|
||||
|
||||
const STORAGE_KEY = 'music.lyrics';
|
||||
// Read once, at import: the play dock re-renders every animation frame, and this is its initial value.
|
||||
const initialOpen = localStorage.getItem(STORAGE_KEY) === '1';
|
||||
|
||||
/**
|
||||
* Whether the lyrics panel is open. Shared by the two microphone buttons — the one in the play dock and
|
||||
* the one on the album header — which are the same switch shown twice, so it lives in a channel rather
|
||||
* than in either component. Seeded from localStorage so the choice survives a reload.
|
||||
*/
|
||||
export const useLyricsOpen = () => {
|
||||
const [open, setOpen] = usePanelChannel<boolean>(MUSIC_LYRICS_CHANNEL, initialOpen);
|
||||
|
||||
// Deliberately not a functional update: useGlobal applies those to the render-time snapshot, and `open`
|
||||
// is that snapshot anyway.
|
||||
const toggleLyrics = () => {
|
||||
localStorage.setItem(STORAGE_KEY, open ? '0' : '1');
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return [open, toggleLyrics] as const;
|
||||
};
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Play, ChevronLeft, Volume2 } from 'lucide-react';
|
||||
import { Play, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
||||
import { WorkspaceLayout } from '../../components/Workspace';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { FavoritesView } from './FavoritesView';
|
||||
import { useMusicPlayer } from '../../MusicPlayer';
|
||||
import type { PlayerTrack } from '../../MusicPlayer';
|
||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
||||
import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_CWD_CHANNEL,
|
||||
@@ -26,6 +31,32 @@ import {
|
||||
type Track,
|
||||
} from './shared';
|
||||
|
||||
// Turning the lyrics on splits THIS panel in two rather than opening a panel of its own: the workspace
|
||||
// system is a layout engine as well as a shell, so a nested WorkspaceLayout with a fixed layout and
|
||||
// components keyed by panel id gets a resizable split with no persistence and no registry entries.
|
||||
const LYRICS_LAYOUT: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'music-detail-split',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'music-detail-list', appType: null }, size: 62 },
|
||||
{ node: { type: 'panel', id: 'music-detail-lyrics', appType: null }, size: 38 },
|
||||
],
|
||||
};
|
||||
|
||||
// The library view is rendered by MusicDetail itself and passed down through context, so toggling the
|
||||
// split moves the same element rather than mounting a second copy — the fetched album, and every request
|
||||
// that produced it, survives the toggle.
|
||||
const LibraryViewContext = createContext<ReactNode>(null);
|
||||
const LibraryViewPanel = () => <>{useContext(LibraryViewContext)}</>;
|
||||
|
||||
const LYRICS_PANELS: PanelComponents = {
|
||||
'music-detail-list': LibraryViewPanel,
|
||||
'music-detail-lyrics': LyricsPanel,
|
||||
};
|
||||
|
||||
const keepLayout = () => {};
|
||||
|
||||
// Right panel of the /music workspace — renders the content of the current 'music:cwd': an album
|
||||
// (tracklist), an artist (album cards grouped by discography type), or a folder grid. Drilling in
|
||||
// updates the shared channel; playback goes through the app-wide player.
|
||||
@@ -35,6 +66,7 @@ export const MusicDetail = () => {
|
||||
const [cwd, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
|
||||
const [favOpen, setFavOpen] = usePanelChannel<boolean>(MUSIC_FAV_CHANNEL, false);
|
||||
const [resync] = usePanelChannel<number>(MUSIC_RESYNC_CHANNEL, 0);
|
||||
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||
|
||||
const [manifest, setManifest] = useState<Record<string, ManifestAlbum>>({});
|
||||
const [libraries, setLibraries] = useState<string[]>([]);
|
||||
@@ -207,9 +239,9 @@ export const MusicDetail = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (favOpen) return <FavoritesView />;
|
||||
|
||||
return (
|
||||
const libraryView = favOpen ? (
|
||||
<FavoritesView />
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
{cwd && (
|
||||
<button
|
||||
@@ -269,6 +301,17 @@ export const MusicDetail = () => {
|
||||
<Play size={18} className="ml-0.5" />
|
||||
</button>
|
||||
<MusicHeart kind="album" favKey={rel} size={24} className="p-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLyrics}
|
||||
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
|
||||
aria-pressed={lyricsOpen}
|
||||
className={`cursor-pointer p-1 hover:text-foreground ${
|
||||
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<MicVocal size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,4 +403,12 @@ export const MusicDetail = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!lyricsOpen) return libraryView;
|
||||
|
||||
return (
|
||||
<LibraryViewContext.Provider value={libraryView}>
|
||||
<WorkspaceLayout layout={LYRICS_LAYOUT} onLayoutChange={keepLayout} components={LYRICS_PANELS} noHeader />
|
||||
</LibraryViewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user