music: replace the dock with an in-panel scrubber on /music

the album view already carries the transport, so the full-width dock was a
second bar costing the workspace a row. the host stays mounted (it owns the
engine) and only withholds its bar on /music; MusicMiniBar draws the scrubber
at the foot of the library panel, with play/pause and the lyrics toggle for
when you browse away from the album that's playing.

player-time now publishes duration alongside position so a scrubber outside
the host's tree can render without a 60hz state channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:47:06 +00:00
co-authored by Claude Opus 5
parent 1be43df75b
commit 81209ae66d
6 changed files with 147 additions and 15 deletions
@@ -0,0 +1,90 @@
import { useRef } from 'react';
import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { fmtClock } from '../apps/Music/shared';
import { seekPlayer } from './player-time';
import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer';
import { usePlayerClock } from './usePlayerClock';
/**
* The player, reduced to what the /music screen does not already show. The album view has the transport
* and the tracklist, so this is the scrubber — plus play/pause and the lyrics toggle, which are the two
* controls you can still want while browsing an album that ISN'T the one playing.
*
* It sits inside the detail panel, which is why the full dock hides on /music: two bars would be one bar
* too many, and the dock's own row costs the workspace its height on every screen.
*/
export const MusicMiniBar = () => {
const { current, playing, toggle } = useMusicPlayer();
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
const { position, duration } = usePlayerClock();
const barRef = useRef<HTMLDivElement>(null);
if (!current) return null;
const onSeekDown = (ev: React.MouseEvent<HTMLDivElement>) => {
const seekAt = (clientX: number) => {
const bar = barRef.current;
if (!bar || !duration) return;
const rect = bar.getBoundingClientRect();
seekPlayer(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);
};
ev.preventDefault();
seekAt(ev.clientX);
const onMove = (moveEv: MouseEvent) => seekAt(moveEv.clientX);
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
return (
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/60 px-4 py-2">
<button
type="button"
onClick={toggle}
title={playing ? 'Pause' : 'Play'}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground hover:opacity-90 active:scale-95"
>
{playing ? <Pause size={15} /> : <Play size={15} className="ml-0.5" />}
</button>
<div className="hidden min-w-0 w-48 shrink-0 sm:block">
<p className="truncate text-xs font-medium text-foreground">{current.title ?? current.file}</p>
{current.artist && <p className="truncate text-[11px] text-muted-foreground">{current.artist}</p>}
</div>
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{fmtClock(position)}
</span>
<div className="min-w-0 flex-1">
<SeekBar
barRef={barRef}
onSeekDown={onSeekDown}
pct={duration ? (position / duration) * 100 : 0}
trackClass="bg-muted"
fillClass="bg-primary"
thumbClass="border-background"
/>
</div>
<span className="w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{fmtClock(duration)}
</span>
<button
type="button"
onClick={toggleLyrics}
title={lyricsOpen ? 'Hide lyrics' : 'Show lyrics'}
aria-pressed={lyricsOpen}
className={`shrink-0 cursor-pointer p-1 hover:text-foreground ${
lyricsOpen ? 'text-primary' : 'text-muted-foreground'
}`}
>
<MicVocal size={16} />
</button>
</div>
);
};
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
@@ -8,6 +8,7 @@ import { MusicHeart } from '../apps/Music/MusicHeart';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
fmtClock,
sortTracks,
trackHomePath,
type AlbumMeta,
@@ -28,12 +29,11 @@ import { useLyricsOpen } from './useLyricsOpen';
// back into the index without restarting playback.
const MUSIC_API = '/api/music';
const fmt = (s: number): string =>
Number.isFinite(s) && s >= 0 ? `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` : '0:00';
export const MusicPlayerHost = () => {
const { token, get, put, delete: del } = useClient();
const navigate = useNavigate();
const { pathname } = useLocation();
const [, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
useMusicPlayer();
@@ -100,7 +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
publishPlayerTime(pos, dur); // the lyrics pane and the /music scrubber live in another tree
isRestoringRef.current = false; // saved position has been applied — safe to persist again
},
onIndex: (i) => {
@@ -219,7 +219,7 @@ export const MusicPlayerHost = () => {
// /music workspace rather than under the dock, reaches it through this registration.
const seekTo = useCallback((sec: number) => {
setPosition(sec);
publishPlayerTime(sec);
publishPlayerTime(sec, durationRef.current);
engineRef.current?.seek(sec);
}, []);
@@ -272,6 +272,11 @@ export const MusicPlayerHost = () => {
if (!current) return null;
// On /music the library screen draws its own MusicMiniBar inside the panel, and the album view already
// has the transport — so the dock would be a second bar taking a full row off the workspace. The host
// stays MOUNTED (it owns the engine); only its bar is withheld.
if (pathname.startsWith('/music')) 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.
return (
@@ -334,7 +339,7 @@ export const MusicPlayerHost = () => {
{/* scrubber + times */}
<span className="hidden w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground md:block">
{fmt(position)}
{fmtClock(position)}
</span>
<div className="min-w-0 flex-1">
<SeekBar
@@ -347,7 +352,7 @@ export const MusicPlayerHost = () => {
/>
</div>
<span className="hidden w-10 shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground md:block">
{fmt(duration)}
{fmtClock(duration)}
</span>
{/* volume */}
@@ -11,18 +11,21 @@
* has to reach it.
*/
let position = 0;
const subscribers = new Set<(sec: number) => void>();
let duration = 0;
const subscribers = new Set<(sec: number, dur: number) => void>();
let seekFn: ((sec: number) => void) | null = null;
export const publishPlayerTime = (sec: number): void => {
export const publishPlayerTime = (sec: number, dur: number): void => {
position = sec;
for (const fn of subscribers) fn(sec);
duration = dur;
for (const fn of subscribers) fn(sec, dur);
};
/** Latest position, for a subscriber that mounts mid-track. */
/** Latest values, for a subscriber that mounts mid-track. */
export const getPlayerTime = (): number => position;
export const getPlayerDuration = (): number => duration;
export const subscribePlayerTime = (fn: (sec: number) => void): (() => void) => {
export const subscribePlayerTime = (fn: (sec: number, dur: number) => void): (() => void) => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
/**
* Position + duration, straight off the engine's per-frame feed.
*
* A scrubber genuinely wants every frame, so — unlike the lyrics — this does re-render at 60fps. Keep it
* in the smallest component that draws the bar: whatever calls this hook repaints with it.
*/
export const usePlayerClock = () => {
const [clock, setClock] = useState(() => ({ position: getPlayerTime(), duration: getPlayerDuration() }));
useEffect(() => subscribePlayerTime((position, duration) => setClock({ position, duration })), []);
return clock;
};
@@ -10,6 +10,7 @@ import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
import {
MUSIC_ROOT,
@@ -246,10 +247,12 @@ export const MusicDetail = () => {
</div>
);
const libraryView = favOpen ? (
<FavoritesView />
const content = favOpen ? (
<div className="min-h-0 flex-1">
<FavoritesView />
</div>
) : (
<div className="h-full overflow-y-auto p-4 md:p-6">
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
{cwd && (
<button
type="button"
@@ -412,6 +415,15 @@ export const MusicDetail = () => {
</div>
);
// The scrubber sits at the foot of this panel instead of the app-wide dock, which hides itself on
// /music: the album view already carries the transport, so all the dock added here was a second row.
const libraryView = (
<div className="flex h-full flex-col">
{content}
<MusicMiniBar />
</div>
);
if (!lyricsOpen) return libraryView;
return (
@@ -39,6 +39,12 @@ export const fmtDuration = (sec?: number): string => {
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
};
/** Seconds → "m:ss" for a running clock: unknown reads as 0:00, never blank, so it doesn't jitter. */
export const fmtClock = (sec: number): string =>
Number.isFinite(sec) && sec >= 0
? `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
: '0:00';
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
export const fuzzyMatch = (query: string, text: string): boolean => {
const q = query.trim().toLowerCase();