the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar, favourites, lyrics toggle and the library vocabulary — ten files. The barrel stops exporting a player it no longer has, and DashboardLayout stops rendering one. The reasoning that kept it was removed rather than refuted. It stayed because the dashboard widget imported useMusicPlayer from officerdev and the platform cannot import from a plugin, so the state had to stay whatever was decided about the UI. The owner moved the widget into the plugin in the previous commit, and the constraint went with it: the whole remaining dependency became one line, DashboardLayout.tsx:66. MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until you notice it already returned null on /music — the mini bar is the transport there, and the host existed purely to own the GaplessEngine. In the panel it does exactly that, and the bar code stays intact for whenever there is a slot. [phase 2] Leaving /music unmounts the host and playback stops. Deferred on the owner's call; the bar was "navigating away must not break the application", and that holds: seekPlayer is optional-chained so a call with no host registered is a no-op, registerPlayerSeek clears only its own registration, the host's cleanup destroys the engine and nulls its ref, and the queue is global state so returning to /music remounts and reloads. Solving it properly needs either a shell slot a plugin can contribute to — which reopens "there is no way to export a component" — or the engine hoisted to module scope, which keeps the rule and loses only the off-route controls. Also: the parked widget now imports the player as a sibling rather than through officerdev, and shared.ts stopped being a re-export shim now that the real file is in the plugin. Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts /example /music /offscale, / and /music both 200, and the player is in the built bundle (music.volume, music:lyrics, now-playing?device=web all present — GaplessEngine is a class name and the production build is minified, so grepping for it proves nothing). Not verified by me: what it looks like in a browser. That needs your eyes.
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||
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.
|
||||
//
|
||||
// Audio is Web Audio (gapless-engine), NOT an <audio> element: it schedules each track to start at the
|
||||
// exact sample the previous one ends, so a continuous mix plays with zero gap on auto-advance. React
|
||||
// holds the queue/index (shared via useMusicPlayer); this host reconciles it with the engine — user
|
||||
// actions (new album, jump, prev/next) command the engine, and the engine's own natural advance mirrors
|
||||
// back into the index without restarting playback.
|
||||
|
||||
const MUSIC_API = '/api/music';
|
||||
|
||||
export const MusicPlayerHost = () => {
|
||||
const { token, get, put, delete: del } = useClient();
|
||||
const { can } = useCapabilities();
|
||||
const canUseMusic = can('music');
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
|
||||
useMusicPlayer();
|
||||
|
||||
const engineRef = useRef<GaplessEngine | null>(null);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [volume, setVolume] = useState(() => {
|
||||
const v = parseFloat(localStorage.getItem('music.volume') ?? '1');
|
||||
return Number.isFinite(v) ? v : 1;
|
||||
});
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [lyricsOpen, toggleLyrics] = useLyricsOpen();
|
||||
|
||||
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
|
||||
|
||||
// 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
|
||||
// clobber the saved position with 0; restoredRef makes restore run once; engineIndexRef is the index the
|
||||
// engine is actually on, used to tell an engine-driven advance apart from a user jump.
|
||||
const positionRef = useRef(0);
|
||||
positionRef.current = position;
|
||||
const durationRef = useRef(0);
|
||||
durationRef.current = duration;
|
||||
const restoredRef = useRef(false);
|
||||
const isRestoringRef = useRef(false);
|
||||
const seekToRef = useRef<number | null>(null);
|
||||
const engineIndexRef = useRef(0);
|
||||
// The engine is created once, so its callbacks would capture first-render closures. useMusicPlayer's
|
||||
// functional setters (syncIndex/setPlaying) read the state captured at THAT render (the initial EMPTY
|
||||
// queue) — calling them from a stale closure wipes the queue. Route them through refs kept current.
|
||||
const syncIndexRef = useRef(syncIndex);
|
||||
syncIndexRef.current = syncIndex;
|
||||
const setPlayingRef = useRef(setPlaying);
|
||||
setPlayingRef.current = setPlaying;
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
|
||||
const withToken = (u: string) => (token ? `${u}${u.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : u);
|
||||
const streamUrl = (t: PlayerTrack) =>
|
||||
withToken(`${MUSIC_API}/stream?path=${encodeURIComponent(`Music/${t.albumRel}/${t.file}`)}`);
|
||||
const coverUrl = (rel: string) => withToken(`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}`);
|
||||
const toEngineTrack = (t: PlayerTrack): EngineTrack => ({ key: `${t.albumRel}/${t.file}`, url: streamUrl(t) });
|
||||
|
||||
const persist = () => {
|
||||
if (!current) return;
|
||||
put('/music/now-playing?device=web', {
|
||||
homePath: trackHomePath(current.albumRel, current.file),
|
||||
dir: `Music/${current.albumRel}`,
|
||||
title: current.title ?? '',
|
||||
artist: current.artist ?? '',
|
||||
album: current.albumRel.split('/').pop() ?? '',
|
||||
durationSec: Math.round(durationRef.current) || 0,
|
||||
positionSec: Math.round(positionRef.current) || 0,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// ── Engine lifecycle (mounted once) ──
|
||||
useEffect(() => {
|
||||
const engine = new GaplessEngine({
|
||||
onTime: (pos, dur) => {
|
||||
positionRef.current = pos;
|
||||
durationRef.current = dur;
|
||||
setPosition(pos);
|
||||
setDuration(dur);
|
||||
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) => {
|
||||
engineIndexRef.current = i; // engine advanced on its own → mirror to UI without restarting
|
||||
syncIndexRef.current(i);
|
||||
},
|
||||
// Queue finished on its own → clear it so the in-flow dock releases its space (no idle bar lingering
|
||||
// after playback). The saved snapshot is left intact, so a reload still resumes where you left off.
|
||||
onEndOfQueue: () => closeRef.current(),
|
||||
onLoadingChange: setLoading,
|
||||
});
|
||||
engineRef.current = engine;
|
||||
engine.setVolume(muted ? 0 : volume);
|
||||
// Satisfy the browser autoplay policy ONCE, on the first user gesture — after that, sticky activation
|
||||
// lets engine.play() resume the context on its own. It MUST be once-only: a persistent listener would
|
||||
// resume the context on every click, overriding a deliberate pause (pause = ctx.suspend()).
|
||||
const unlock = () => engine.unlock();
|
||||
document.addEventListener('pointerdown', unlock, { once: true });
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', unlock);
|
||||
engine.destroy();
|
||||
engineRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
||||
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
||||
//
|
||||
// Also skipped without the `music` capability. This host is mounted by the shell for every account, so it
|
||||
// used to reach for `/music/now-playing` on a member's very first paint and 403.
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
if (!canUseMusic) return;
|
||||
restoredRef.current = true;
|
||||
if (queue.length) return;
|
||||
(async () => {
|
||||
const snap = await get<NowPlaying | null>('/music/now-playing?device=web').catch(() => null);
|
||||
if (!snap?.homePath) return;
|
||||
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
|
||||
const file = snap.homePath.split('/').pop() ?? '';
|
||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`).catch(() => null);
|
||||
if (!meta) return;
|
||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||
albumRel,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
const idx = Math.max(
|
||||
0,
|
||||
q.findIndex((t) => t.file === file),
|
||||
);
|
||||
isRestoringRef.current = true;
|
||||
seekToRef.current = snap.positionSec > 0 ? snap.positionSec : null;
|
||||
loadQueue(q, idx);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A NEW queue (playQueue/loadQueue set a fresh array) → (re)load the engine at that index. A pending
|
||||
// restore offset starts it paused at position; otherwise autoplay follows the queue's `playing` flag.
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine) return;
|
||||
engineIndexRef.current = index;
|
||||
const seekTo = seekToRef.current ?? 0;
|
||||
seekToRef.current = null;
|
||||
engine.load(queue.map(toEngineTrack), index, playing, seekTo);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue]);
|
||||
|
||||
// Index changed on the SAME queue: a user jump / prev-next (engine advance already matches, so it no-ops).
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine || !queue.length) return;
|
||||
if (index === engineIndexRef.current) return;
|
||||
engineIndexRef.current = index;
|
||||
engine.skipTo(index);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index]);
|
||||
|
||||
// Play/pause.
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine || !current) return;
|
||||
if (playing) engine.play();
|
||||
else engine.pause();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playing]);
|
||||
|
||||
// Volume / mute.
|
||||
useEffect(() => {
|
||||
engineRef.current?.setVolume(muted ? 0 : volume);
|
||||
}, [volume, muted]);
|
||||
|
||||
// Persist on play/pause + track change (not during the restore load).
|
||||
useEffect(() => {
|
||||
if (isRestoringRef.current) return;
|
||||
persist();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [trackKey, playing]);
|
||||
|
||||
// Heartbeat while playing, so the saved position keeps up.
|
||||
useEffect(() => {
|
||||
if (!playing || !current) return;
|
||||
const id = setInterval(persist, 10000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playing, trackKey]);
|
||||
|
||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
setVolume(v);
|
||||
setMuted(v === 0);
|
||||
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, durationRef.current);
|
||||
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>) => {
|
||||
const seekAt = (clientX: number) => {
|
||||
const bar = barRef.current;
|
||||
if (!bar || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
seekTo(pct * duration);
|
||||
};
|
||||
e.preventDefault();
|
||||
seekAt(e.clientX);
|
||||
const onMove = (ev: MouseEvent) => seekAt(ev.clientX);
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const subtitle = current?.artist ?? current?.albumRel.split('/').slice(-2, -1)[0] ?? '';
|
||||
const pct = duration ? (position / duration) * 100 : 0;
|
||||
|
||||
// Closing the dock also clears the saved "currently playing" snapshot, so it doesn't get restored on
|
||||
// the next load. (Merely close()-ing the local queue would leave the server snapshot to bring it back.)
|
||||
const handleClose = () => {
|
||||
del('/music/now-playing?device=web').catch(() => {});
|
||||
close();
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
|
||||
{/* cover + info — a real link to the playing album, so it cmd-clicks like anything else */}
|
||||
<Link
|
||||
to={musicPath(current.albumRel)}
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{/* 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">
|
||||
{fmtClock(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">
|
||||
{fmtClock(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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user