From 81209ae66d82522e0a474f549c1c2d98f60ef8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 16:47:06 +0000 Subject: [PATCH] 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 --- .../src/MusicPlayer/MusicMiniBar.tsx | 90 +++++++++++++++++++ .../src/MusicPlayer/MusicPlayerHost.tsx | 19 ++-- .../officerdev/src/MusicPlayer/player-time.ts | 13 +-- .../src/MusicPlayer/usePlayerClock.ts | 16 ++++ .../officerdev/src/apps/Music/MusicDetail.tsx | 18 +++- .../officerdev/src/apps/Music/shared.ts | 6 ++ 6 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 src/workspaces/officerdev/src/MusicPlayer/MusicMiniBar.tsx create mode 100644 src/workspaces/officerdev/src/MusicPlayer/usePlayerClock.ts diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicMiniBar.tsx b/src/workspaces/officerdev/src/MusicPlayer/MusicMiniBar.tsx new file mode 100644 index 00000000..033de118 --- /dev/null +++ b/src/workspaces/officerdev/src/MusicPlayer/MusicMiniBar.tsx @@ -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(null); + + if (!current) return null; + + const onSeekDown = (ev: React.MouseEvent) => { + 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 ( +
+ + +
+

{current.title ?? current.file}

+ {current.artist &&

{current.artist}

} +
+ + + {fmtClock(position)} + +
+ +
+ + {fmtClock(duration)} + + + +
+ ); +}; diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx index 051d549f..583d9958 100644 --- a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx +++ b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx @@ -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(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 */} - {fmt(position)} + {fmtClock(position)}
{ />
- {fmt(duration)} + {fmtClock(duration)} {/* volume */} diff --git a/src/workspaces/officerdev/src/MusicPlayer/player-time.ts b/src/workspaces/officerdev/src/MusicPlayer/player-time.ts index 27bc305e..c75a4abe 100644 --- a/src/workspaces/officerdev/src/MusicPlayer/player-time.ts +++ b/src/workspaces/officerdev/src/MusicPlayer/player-time.ts @@ -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); diff --git a/src/workspaces/officerdev/src/MusicPlayer/usePlayerClock.ts b/src/workspaces/officerdev/src/MusicPlayer/usePlayerClock.ts new file mode 100644 index 00000000..b4332121 --- /dev/null +++ b/src/workspaces/officerdev/src/MusicPlayer/usePlayerClock.ts @@ -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; +}; diff --git a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx index b1fa40e9..a74d2bea 100644 --- a/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Music/MusicDetail.tsx @@ -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 = () => { ); - const libraryView = favOpen ? ( - + const content = favOpen ? ( +
+ +
) : ( -
+
{cwd && (
); + // 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 = ( +
+ {content} + +
+ ); + if (!lyricsOpen) return libraryView; return ( diff --git a/src/workspaces/officerdev/src/apps/Music/shared.ts b/src/workspaces/officerdev/src/apps/Music/shared.ts index 2195d6be..8a761aa0 100644 --- a/src/workspaces/officerdev/src/apps/Music/shared.ts +++ b/src/workspaces/officerdev/src/apps/Music/shared.ts @@ -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();