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:
2026-08-15 14:42:13 +00:00
parent f1bd75853d
commit 0a55964db5
23 changed files with 232 additions and 282 deletions
+393
View File
@@ -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>
);
};