Files
music/web/MusicPlayerHost.tsx
T
Claude Opus 5 6e07da7a36 music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 —
41 files, unchanged from the tree they left.

  manifest.ts   identity, one permission, ffmpeg/ffprobe declared
  api/          the sidecar proxy; the prefix comes from mountPrefix()
  sidecar/      the whole /api/music contract — indexing, streaming, per-user state
  db/           music_favorites, _playlists, _playlist_items, _now_playing
  web/          panels, layout, and the player: engine, bar, lyrics, favourites
  cliamp/       the second playback path, parked — not working, kept deliberately
  widgets/      the dashboard widget, parked — plugins cannot contribute widgets
  assets/       icon.png, the dock tile
  scripts/      the reindex CLI

PLUGIN.md is the design record: what moved, what stayed, what broke, and why.
MUSIC_API.md is the contract the phone and tablet apps speak, and the reason
the sidecar's HTTP shape is not free to change.

── It does not build here, and that is the point ──

The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*`
through the workspace links in its own node_modules. Measured from this
directory, outside the platform checkout, every one of them fails to resolve —
7 imports in the backend, ~29 in the frontend.

So this repository is the source of truth, not yet a buildable unit. Making it
one means the host API becoming something a plugin can depend on rather than
something it reaches into. That is the next problem, and having the code here
is what makes it unavoidable rather than theoretical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:34:51 +00:00

394 lines
16 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions';
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 } = usePermissions();
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` permission. 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>
);
};