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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user