The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
394 lines
16 KiB
TypeScript
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` 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>
|
|
);
|
|
};
|