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>
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 { 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user