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
@@ -1,43 +0,0 @@
import { Heart } from 'lucide-react';
import type { FavoriteKind } from './shared';
import { useMusicFavorites } from './useMusicFavorites';
/**
* A heart toggle for a favoritable thing (track / album / artist). Reads and writes the shared
* favorites cache, so every heart for the same key stays in sync and flips optimistically. Stops click
* propagation so it works inside clickable rows/cards. Renders nothing without a key.
*/
export const MusicHeart = ({
kind,
favKey,
size = 18,
className = '',
hoverReveal = false,
}: {
kind: FavoriteKind;
favKey: string;
size?: number;
className?: string;
/** When set, a NOT-favorited heart is hidden until the enclosing `group` is hovered/focused; a
* favorited (filled) heart always stays visible. Keeps dense lists uncluttered. */
hoverReveal?: boolean;
}) => {
const { isFavorite, toggle } = useMusicFavorites();
if (!favKey) return null;
const on = isFavorite(kind, favKey);
const reveal = hoverReveal && !on ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : '';
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggle(kind, favKey);
}}
title={on ? 'Remove from favorites' : 'Add to favorites'}
aria-label={on ? 'Remove from favorites' : 'Add to favorites'}
className={`flex cursor-pointer items-center justify-center transition-colors ${reveal} ${className}`}
>
<Heart size={size} className={on ? 'fill-red-500 text-red-500' : 'text-muted-foreground hover:text-foreground'} />
</button>
);
};
@@ -1,393 +0,0 @@
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 '../apps/FileViewer/renderers/SeekBar';
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>
);
};
@@ -1,191 +0,0 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { GaplessEngine, type EngineTrack } from './gapless-engine';
// These tests exist because the failure they cover is inaudible to the code and obvious to the ear: two
// sources playing the same track at once. Nothing throws, no state looks wrong, `cur` points at a real
// node that is really playing — there is simply a second one nobody is holding. The only way to catch it
// is to count the nodes that were started.
type StartCall = { when: number; offset: number };
class FakeSource {
buffer: AudioBuffer | null = null;
onended: (() => void) | null = null;
started: StartCall | null = null;
stopped = false;
disconnected = false;
constructor(private ctx: FakeContext) {}
connect(): void {}
disconnect(): void {
this.disconnected = true;
}
start(when = 0, offset = 0): void {
if (this.started) throw new Error('InvalidStateError: already started');
this.started = { when, offset };
this.ctx.started.push(this);
}
stop(): void {
if (!this.started) throw new Error('InvalidStateError: not started');
this.stopped = true;
}
/** What the browser does at the end of the buffer (or at stop()) — the engine's advance trigger. */
end(): void {
this.onended?.();
}
}
class FakeContext {
currentTime = 0;
state: 'running' | 'suspended' | 'closed' = 'running';
destination = {};
started: FakeSource[] = [];
createGain() {
return { gain: { value: 1 }, connect: () => {} };
}
createBufferSource() {
return new FakeSource(this) as unknown as AudioBufferSourceNode & FakeSource;
}
async resume(): Promise<void> {
this.state = 'running';
}
async suspend(): Promise<void> {
this.state = 'suspended';
}
async close(): Promise<void> {
this.state = 'closed';
}
async decodeAudioData(): Promise<AudioBuffer> {
// Decoding is the slow step the bug hides inside — a macrotask is enough to model "not instant".
await new Promise((r) => setTimeout(r, 0));
return { duration: 100 } as AudioBuffer;
}
}
/**
* Sources producing sound RIGHT NOW. The `when <= currentTime` clause is not a detail: a gapless engine
* always has the next track already started, scheduled at the current track's end. Counting those as
* audible would make the healthy state look like the bug.
*/
const audible = (ctx: FakeContext) =>
ctx.started.filter((s) => !s.stopped && s.started !== null && s.started.when <= ctx.currentTime);
let ctx: FakeContext;
const originalFetch = globalThis.fetch;
const originalCtor = (globalThis as Record<string, unknown>).AudioContext;
const QUEUE: EngineTrack[] = [
{ key: 'a', url: '/stream/a' },
{ key: 'b', url: '/stream/b' },
{ key: 'c', url: '/stream/c' },
];
// Let every pending decode + its continuation run.
const settle = async () => {
for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
};
beforeEach(() => {
ctx = new FakeContext();
(globalThis as Record<string, unknown>).AudioContext = function () {
return ctx;
};
globalThis.fetch = (async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
})) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
(globalThis as Record<string, unknown>).AudioContext = originalCtor;
});
describe('GaplessEngine', () => {
test('load(autoplay) followed by play() starts the track exactly once', async () => {
// The host's queue effect and playing effect both fire in the same commit when a queue starts from a
// paused player. This is that commit, and it used to produce two sources of the same track.
const engine = new GaplessEngine({});
engine.load(QUEUE, 0, true);
engine.play();
await settle();
expect(audible(ctx)).toHaveLength(1);
engine.destroy();
});
test('repeated play() during the decode does not stack sources', async () => {
const engine = new GaplessEngine({});
engine.load(QUEUE, 0, true);
engine.play();
engine.play();
engine.play();
await settle();
expect(audible(ctx)).toHaveLength(1);
engine.destroy();
});
test('pause then play while still decoding does not stack sources', async () => {
// The impatient-user path: nothing is audible yet because the file is still downloading, so the play
// button gets hit again.
const engine = new GaplessEngine({});
engine.load(QUEUE, 0, true);
engine.pause();
engine.play();
await settle();
expect(audible(ctx)).toHaveLength(1);
engine.destroy();
});
test('a stale source ending does not advance the queue', async () => {
// Defence in depth: even if a source outlives its bookkeeping, only `cur` may move the index.
const seen: number[] = [];
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
engine.load(QUEUE, 0, true);
await settle();
const first = audible(ctx)[0]!;
engine.skipTo(2); // bumps the generation and stops `first`
await settle();
first.end(); // the browser still delivers its onended
expect(seen).toEqual([]); // skipTo is a user action; only a NATURAL boundary reports an index
engine.destroy();
});
test('switching queues leaves nothing from the old one sounding', async () => {
const engine = new GaplessEngine({});
engine.load(QUEUE, 0, true);
await settle();
engine.load([{ key: 'z', url: '/stream/z' }], 0, true);
await settle();
expect(audible(ctx)).toHaveLength(1);
expect(audible(ctx)[0]!.started).not.toBeNull();
engine.destroy();
});
test('a natural boundary advances the index exactly once', async () => {
const seen: number[] = [];
const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
engine.load(QUEUE, 0, true);
await settle();
const cur = audible(ctx)[0]!;
cur.end();
await settle();
expect(seen).toEqual([1]);
engine.destroy();
});
test('destroy() silences every source it started', async () => {
const engine = new GaplessEngine({});
engine.load(QUEUE, 0, true);
await settle();
engine.destroy();
expect(audible(ctx)).toHaveLength(0);
});
});
@@ -1,375 +0,0 @@
// Sample-accurate gapless playback engine (Web Audio).
//
// The HTML5 <audio> element cannot play tracks back-to-back without a gap: advancing swaps the element's
// `src`, which re-fetches + re-buffers the next file. For a continuous DJ mix (already silence-trimmed at
// the edges) that gap is audible. This engine instead decodes each track to an AudioBuffer and schedules
// the NEXT track's AudioBufferSourceNode to `start()` at the precise AudioContext time the current track
// ends — so the seam is sample-accurate, zero gap.
//
// Cost of that guarantee: it must download + decode the WHOLE next file to PCM ahead of time (a ~10-min
// track ≈ 200 MB in memory), so we cache only a few decoded buffers. Startup / manual-skip therefore has
// a decode latency (surfaced via onLoadingChange); auto-advance is pre-decoded so it's instant.
export type EngineTrack = { key: string; url: string };
export type EngineCallbacks = {
/** The engine advanced to a new track on its OWN (natural boundary) — mirror it into UI state. */
onIndex?: (index: number) => void;
/** Current position + duration (seconds). Fires ~per animation frame while playing, and on load/seek. */
onTime?: (positionSec: number, durationSec: number) => void;
/** Playback stopped because the queue ran out (host should reflect paused state). */
onEndOfQueue?: () => void;
/** True while the CURRENT track is being fetched/decoded (startup / skip / seek). */
onLoadingChange?: (loading: boolean) => void;
};
const CACHE_MAX = 3; // decoded buffers kept (current + next + a little headroom); bounds memory
export class GaplessEngine {
private ctx: AudioContext | null = null;
private gain: GainNode | null = null;
private volume = 1;
private buffers = new Map<string, AudioBuffer>(); // url → decoded PCM (insertion-ordered LRU)
private inflight = new Map<string, Promise<AudioBuffer | null>>();
private queue: EngineTrack[] = [];
private index = 0;
private cur: AudioBufferSourceNode | null = null;
private nxt: AudioBufferSourceNode | null = null;
// EVERY source node this engine has started and that has not yet ended. `cur`/`nxt` are the two it is
// reasoning about; this is the set it is responsible for silencing. They diverged once — a duplicate
// begin() left a source playing that nothing held a reference to, so nothing could ever stop it — and
// an orphan in Web Audio is unstoppable and inaudible to the code. Add here, stop from here.
private live = new Set<AudioBufferSourceNode>();
private curBaseTime = 0; // ctx time that corresponds to position 0 of the current track
private curDuration = 0;
private nextStartAt = 0; // ctx time the scheduled `nxt` will begin (= current track's end)
private pendingOffset = 0; // where the current track should (re)start from — seek/resume offset
private started = false; // has the current track's source actually been started?
private playing = false;
private gen = 0; // bumped on any disruptive change; stale async/onended callbacks check it and bail
// The generation a begin() is currently in flight for, or -1. `gen` alone cannot express this: it marks
// a change, and two begin() calls for the SAME generation are exactly the case that must be refused.
private beginGen = -1;
private raf = 0;
private cb: EngineCallbacks;
constructor(cb: EngineCallbacks) {
this.cb = cb;
}
private ensureCtx(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext();
this.gain = this.ctx.createGain();
this.gain.gain.value = this.volume;
this.gain.connect(this.ctx.destination);
}
return this.ctx;
}
/** Resume the context from a user gesture (autoplay policy). Safe to call repeatedly. */
unlock(): void {
const ctx = this.ensureCtx();
if (ctx.state === 'suspended') void ctx.resume();
}
setVolume(v: number): void {
this.volume = v;
if (this.gain) this.gain.gain.value = v;
}
// ── Decoding (fetch full file → PCM), deduped + LRU-capped ──
private decode(url: string): Promise<AudioBuffer | null> {
const cached = this.buffers.get(url);
if (cached) return Promise.resolve(cached);
const existing = this.inflight.get(url);
if (existing) return existing;
const ctx = this.ensureCtx();
const p = (async () => {
try {
const res = await fetch(url);
if (!res.ok) return null;
const bytes = await res.arrayBuffer();
const decoded = await ctx.decodeAudioData(bytes);
this.buffers.set(url, decoded);
this.evict();
return decoded;
} catch {
return null;
} finally {
this.inflight.delete(url);
}
})();
this.inflight.set(url, p);
return p;
}
private evict(): void {
while (this.buffers.size > CACHE_MAX) {
const keep = new Set([this.queue[this.index]?.url, this.queue[this.index + 1]?.url]);
let removed = false;
for (const k of this.buffers.keys()) {
if (!keep.has(k)) {
this.buffers.delete(k);
removed = true;
break;
}
}
if (!removed) break; // everything left is current/next — stop
}
}
// ── Public control surface ──
/** Load a queue and either start it (autoplay) or prepare it paused at `seekTo`. */
load(queue: EngineTrack[], index: number, autoplay: boolean, seekTo = 0): void {
this.gen++;
this.stopSources();
this.queue = queue;
this.index = queue.length ? Math.max(0, Math.min(index, queue.length - 1)) : 0;
this.playing = autoplay && queue.length > 0;
this.started = false;
this.pendingOffset = Math.max(0, seekTo);
this.curDuration = 0;
this.curBaseTime = 0;
this.startTicker();
if (!queue.length) return;
if (autoplay) void this.begin(this.gen);
else void this.prepare(this.gen);
}
/** Manual jump to an arbitrary index (prev / next button / track click). A small decode hitch is fine. */
skipTo(index: number): void {
this.gen++;
this.stopSources();
this.index = Math.max(0, Math.min(index, this.queue.length - 1));
this.pendingOffset = 0;
this.started = false;
this.curDuration = 0;
if (this.playing) void this.begin(this.gen);
else void this.prepare(this.gen);
}
seek(sec: number): void {
if (!this.queue[this.index]) return;
this.gen++;
this.stopSources();
this.pendingOffset = Math.max(0, this.curDuration ? Math.min(sec, this.curDuration) : sec);
this.started = false;
if (this.playing) {
void this.begin(this.gen);
} else {
this.cb.onTime?.(this.pendingOffset, this.curDuration);
void this.prepare(this.gen);
}
}
play(): void {
this.playing = true;
const ctx = this.ensureCtx();
if (!this.started) void this.begin(this.gen);
else if (ctx.state === 'suspended') void ctx.resume();
}
pause(): void {
this.playing = false;
if (this.ctx && this.ctx.state === 'running') void this.ctx.suspend();
}
destroy(): void {
this.gen++;
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
this.stopSources();
this.buffers.clear();
this.inflight.clear();
if (this.ctx) {
void this.ctx.close();
this.ctx = null;
this.gain = null;
}
}
// ── Internals ──
/** Decode the current track without starting it — paused/prepared (restore, or seek while paused). */
private async prepare(gen: number): Promise<void> {
const t = this.queue[this.index];
if (!t) return;
this.cb.onLoadingChange?.(true);
const buf = await this.decode(t.url);
this.cb.onLoadingChange?.(false);
if (gen !== this.gen || !buf) return;
this.curDuration = buf.duration;
this.cb.onTime?.(Math.min(this.pendingOffset, buf.duration), buf.duration);
void this.preloadNext(gen);
}
/**
* Start the current track at `pendingOffset` (decoding first if needed).
*
* Re-entrant calls for the same generation are REFUSED, and that guard is the whole reason this method
* is not simply idempotent-by-gen. `started` only becomes true at the very end, after a fetch and a
* full decode — seconds, for a long track. Anything that calls begin() in that window sees
* `started === false` and starts a second, parallel decode of the same track, and both finish and both
* call startBuffer(): two sources of the same audio playing at once, only one of them in `cur`.
*
* That was not a rare race. Starting a queue from a paused player fired it every single time: the host
* commits a new queue and `playing: true` together, its queue effect calls load(autoplay) → begin, and
* its playing effect then calls play() → begin again, same generation. The audible result compounds —
* the untracked twin keeps its own onended, so at the track boundary advance() runs twice, the index
* jumps two tracks and a second source is promoted while the first is still sounding.
*/
private async begin(gen: number): Promise<void> {
if (this.beginGen === gen) return;
this.beginGen = gen;
try {
const ctx = this.ensureCtx();
if (ctx.state === 'suspended') await ctx.resume();
if (gen !== this.gen) return;
const t = this.queue[this.index];
if (!t) return;
this.cb.onLoadingChange?.(true);
const buf = await this.decode(t.url);
this.cb.onLoadingChange?.(false);
if (gen !== this.gen || !buf) return;
this.startBuffer(buf, this.pendingOffset, gen);
void this.preloadNext(gen);
} finally {
// Released even on the bail paths: a decode that fails must not leave this generation permanently
// unable to start, or a failed track would wedge the player until something bumped `gen`.
if (this.beginGen === gen) this.beginGen = -1;
}
}
private startBuffer(buf: AudioBuffer, offset: number, gen: number): void {
const ctx = this.ensureCtx();
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(this.gain!);
const startAt = ctx.currentTime;
const off = Math.min(Math.max(0, offset), buf.duration);
src.start(startAt, off);
this.live.add(src);
src.onended = () => this.onSourceEnded(src, gen);
this.cur = src;
this.curDuration = buf.duration;
this.curBaseTime = startAt - off; // position = ctx.currentTime - curBaseTime
this.started = true;
this.playing = true;
}
/** Decode index+1 and schedule it to begin exactly when the current track ends. */
private async preloadNext(gen: number): Promise<void> {
if (this.nxt) {
const stale = this.nxt;
this.live.delete(stale);
try {
stale.onended = null;
stale.stop();
} catch {
/* not started */
}
try {
stale.disconnect();
} catch {
/* already disconnected */
}
this.nxt = null;
}
const forIndex = this.index;
const nextTrack = this.queue[forIndex + 1];
if (!nextTrack || !this.started) return;
const buf = await this.decode(nextTrack.url);
if (gen !== this.gen || this.index !== forIndex || !buf) return;
const ctx = this.ensureCtx();
const boundary = this.curBaseTime + this.curDuration;
if (boundary <= ctx.currentTime) return; // already past — advance() will start it fresh
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(this.gain!);
src.start(boundary, 0);
this.live.add(src);
src.onended = () => this.onSourceEnded(src, gen);
this.nxt = src;
this.nextStartAt = boundary;
}
/**
* A source finished. Only the one the engine considers CURRENT may drive the queue forward.
*
* Without the identity check, any source that outlives its bookkeeping still advances the queue when it
* ends — so one stray node does not just play unwanted audio, it desynchronises the index for
* everything after it. `cur` is the single source of truth for "what is playing"; ending anything else
* is bookkeeping, not an event.
*/
private onSourceEnded(src: AudioBufferSourceNode, gen: number): void {
this.live.delete(src);
if (gen !== this.gen || this.cur !== src) return;
this.advance();
}
/** Fired at a track boundary (its source ended): move to the next track. */
private advance(): void {
if (this.index + 1 >= this.queue.length) {
this.playing = false;
this.started = false;
this.cur = null;
this.cb.onEndOfQueue?.();
return;
}
this.index++;
const promoted = this.nxt;
this.nxt = null;
if (promoted?.buffer) {
// The next source was scheduled to start at the exact boundary — it's already playing seamlessly.
this.cur = promoted;
this.curDuration = promoted.buffer.duration;
this.curBaseTime = this.nextStartAt;
this.started = true;
this.cb.onIndex?.(this.index);
void this.preloadNext(this.gen);
} else {
// Next wasn't decoded in time (rare) — start it now, accepting a tiny gap this once.
this.cb.onIndex?.(this.index);
this.pendingOffset = 0;
this.started = false;
void this.begin(this.gen);
}
}
/** Silence everything this engine has started. Iterates `live`, not just cur/nxt — see its declaration. */
private stopSources(): void {
for (const s of this.live) {
try {
s.onended = null;
s.stop();
} catch {
/* not started */
}
try {
s.disconnect();
} catch {
/* already disconnected */
}
}
this.live.clear();
this.cur = null;
this.nxt = null;
}
private startTicker(): void {
if (this.raf) return;
const tick = () => {
this.raf = requestAnimationFrame(tick);
if (!this.ctx || !this.started || !this.playing) return;
const pos = this.ctx.currentTime - this.curBaseTime;
this.cb.onTime?.(Math.max(0, Math.min(pos, this.curDuration)), this.curDuration);
};
this.raf = requestAnimationFrame(tick);
}
}
@@ -1,50 +0,0 @@
// The music player the SHELL hosts.
//
// ── Why this is still in the platform after music became a plugin ──
//
// Music was extracted on 2026-08-15 (`plugins/music/`) and this directory deliberately did not go with
// it. It is the one seam that extraction could not close, and the reason is not the overlay — it is the
// state:
//
// `useMusicPlayer` is imported from `officerdev` by `src/workspaces/widgets/MusicPlayer/`, the
// dashboard widget, which is out of scope and stays. The platform cannot import from a plugin, so the
// player state stays here whatever is decided about the UI around it — and two copies of it would mean
// two audio engines fighting over one pair of speakers.
//
// Given the state had to stay, the engine and the bar stayed with it rather than being split from the
// thing they drive. `MusicPlayerHost` is mounted once by `DashboardLayout`, OUTSIDE `<Routes>`, which is
// what makes playback survive navigation — and a plugin has no way to ask for that. Contributing one
// would mean a shell slot that renders a plugin-provided component on every route, which is exactly the
// escape hatch the plugin system deleted on purpose: there is no way to export a component, and that is
// what makes "every plugin route is a Workspace" a property of the shape rather than a rule to remember.
//
// The seam is inert without the plugin. `MusicPlayerHost` gates on `can('music')`, and `music` is now the
// plugin's permission — registered at install, gone at uninstall — so the overlay switches itself off
// with the plugin and no code here knows why.
//
// ── What the plugin imports, and from where ──
//
// The player API is below, on the `officerdev` barrel. The library VOCABULARY — `shared.ts`, the paths,
// sorting and tag shapes — is not: it declares `DirEntry`, `Track` and `Manifest`, names the barrel
// already spends on the FileBrowser. `plugins/music/web/shared.ts` takes it from the package's declared
// `officerdev/MusicPlayer/shared` subpath instead, which keeps one definition without renaming a type on
// its way through a barrel.
export { useMusicPlayer } from './useMusicPlayer';
export type { PlayerTrack, MusicPlayerState } from './useMusicPlayer';
export { MusicPlayerHost } from './MusicPlayerHost';
export { MusicHeart } from './MusicHeart';
export { useMusicFavorites } from './useMusicFavorites';
// The engine↔UI bridge. Module-level singletons on purpose: the lyrics pane and the /music scrubber live
// in another React tree from the host that owns the engine, so they meet here rather than through props.
export {
publishPlayerTime,
subscribePlayerTime,
registerPlayerSeek,
seekPlayer,
getPlayerTime,
getPlayerDuration,
} from './player-time';
export { useLyricsOpen } from './useLyricsOpen';
@@ -1,42 +0,0 @@
/**
* Playback position, published outside React.
*
* The lyrics pane no longer lives in the player's subtree — it renders in a panel of the /music
* workspace — so the position has to cross the tree. It cannot cross as state: the engine reports a new
* position every animation frame, and a shared state channel would re-render every consumer 60× a
* second. Instead the host pushes into this module and subscribers decide for themselves what is worth
* a render (the lyrics pane only re-renders when the ACTIVE LINE changes, roughly once a line).
*
* Seek travels the other way for the same reason: the engine is the host's, but a click on a lyric line
* has to reach it.
*/
let position = 0;
let duration = 0;
const subscribers = new Set<(sec: number, dur: number) => void>();
let seekFn: ((sec: number) => void) | null = null;
export const publishPlayerTime = (sec: number, dur: number): void => {
position = sec;
duration = dur;
for (const fn of subscribers) fn(sec, dur);
};
/** Latest values, for a subscriber that mounts mid-track. */
export const getPlayerTime = (): number => position;
export const getPlayerDuration = (): number => duration;
export const subscribePlayerTime = (fn: (sec: number, dur: number) => void): (() => void) => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
};
export const registerPlayerSeek = (fn: (sec: number) => void): (() => void) => {
seekFn = fn;
return () => {
if (seekFn === fn) seekFn = null;
};
};
export const seekPlayer = (sec: number): void => seekFn?.(sec);
@@ -1,152 +0,0 @@
// Shared types/helpers for the /music workspace panels (MusicBrowser + MusicDetail), which coordinate
// via the `?path=` search param and play through the app-wide useMusicPlayer.
import { useSearchParams } from 'react-router';
export const MUSIC_ROOT = 'Music';
export const MUSIC_FAV_CHANNEL = 'music:favorites';
// Bumped (to a fresh nonce) when a library reindex finishes, so BOTH panels re-run their manifest /
// listing / meta fetches — otherwise only the panel that triggered the reindex refreshes.
export const MUSIC_RESYNC_CHANNEL = 'music:resync';
// Album folders are named "[year] Album Name" → display as "Album Name" + year.
const ALBUM_NAME_RE = /^\[(\d{4})\]\s*(.+)$/;
export const parseAlbumName = (name: string): { title: string; year?: string } => {
const m = ALBUM_NAME_RE.exec(name.trim());
return m ? { title: m[2]!.trim(), year: m[1] } : { title: name };
};
export type DirEntry = { name: string; type: 'directory' | 'file'; size: number; modifiedAt: number };
export type LsResult = { entries: DirEntry[] };
export type ManifestAlbum = { v: string; cover: boolean; tracks: number; disco?: boolean };
export type Manifest = { albums: Record<string, ManifestAlbum> };
export type Track = {
file: string;
title?: string;
artist?: string;
albumArtist?: string;
track?: string;
durationSec?: number;
};
export type AlbumMeta = { path: string; cover?: string; tracks: Track[] };
/** Seconds → "m:ss" (or "h:mm:ss" once past an hour); '' when unknown. */
export const fmtDuration = (sec?: number): string => {
if (!sec || sec <= 0) return '';
const s = Math.round(sec);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
};
/** Seconds → "m:ss" for a running clock: unknown reads as 0:00, never blank, so it doesn't jitter. */
export const fmtClock = (sec: number): string =>
Number.isFinite(sec) && sec >= 0
? `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
: '0:00';
/** Case-insensitive subsequence fuzzy match: every char of `query` appears in order within `text`. */
export const fuzzyMatch = (query: string, text: string): boolean => {
const q = query.trim().toLowerCase();
if (!q) return true;
const t = text.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) if (t[ti] === q[qi]!) qi++;
return qi === q.length;
};
/** Parse a track-number tag ("7", "07", "7/14") to a number, or null when absent/unparseable. */
const trackNo = (t: Track): number | null => {
const raw = t.track?.split('/')[0]?.trim();
if (!raw) return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
};
/**
* Canonical album track order: by the `track` NUMBER, falling back to the tag title only for tracks
* that have no number (numbered tracks always precede unnumbered ones; filename breaks a final tie).
* meta.json is in ffprobe/readdir order (arbitrary), so every consumer must sort with this.
*/
export const sortTracks = <T extends Track>(tracks: T[]): T[] => {
const key = (t: Track) => (t.title || t.file).toLowerCase();
return [...tracks].sort((a, b) => {
const na = trackNo(a);
const nb = trackNo(b);
if (na !== null && nb !== null) return na - nb || key(a).localeCompare(key(b));
if (na !== null) return -1;
if (nb !== null) return 1;
return key(a).localeCompare(key(b));
});
};
export type Discography = { artist: string; albums: Record<string, string> };
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
/** Per-user "currently playing" snapshot (GET/PUT /api/music/now-playing). */
export type NowPlaying = {
homePath: string;
dir: string;
title: string;
artist: string;
album: string;
durationSec: number;
positionSec: number;
updatedAt: string;
};
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
export const isAudio = (n: string) => {
const d = n.lastIndexOf('.');
return d >= 0 && AUDIO_EXT.has(n.slice(d + 1).toLowerCase());
};
// Section order for an artist's discography.
export const TYPE_ORDER = [
'Studio',
'Live',
'Compilation',
'EP',
'Single',
'Soundtrack',
'Remix',
'DJ-Mix',
'Demo',
'Mixtape',
'Bootleg',
'Other',
];
export const coverUrl = (rel: string, token: string | null) =>
`/api/music/cover?path=${encodeURIComponent(rel)}${token ? `&token=${encodeURIComponent(token)}` : ''}`;
/** Path (home-relative) → rel (relative to the Music root). */
export const toRel = (cwd: string | null) => (cwd ? cwd.slice(MUSIC_ROOT.length + 1) : '');
// Where you are in the library is `/music?path=<rel>`, not a `music:cwd` channel. A query param rather
// than `/music/*` because the location is one of several things this screen holds (the lyrics split and
// the favorites view are the others), and because a splat would have to be the last segment of the
// route — the same reason /chat spells its group that way. `rel === ''` is the library root, which is
// the bare /music and a real state, so there is no redirect guard.
export const MUSIC_PATH_PARAM = 'path';
/** Link target for a library location. `rel` is relative to the Music root; '' is the root itself. */
export const musicPath = (rel: string) => (rel ? `/music?${MUSIC_PATH_PARAM}=${encodeURIComponent(rel)}` : '/music');
/** Link target for the parent of `rel` — '' (the root) is its own parent, which is where "up" stops. */
export const musicParentPath = (rel: string) => musicPath(rel.split('/').slice(0, -1).join('/'));
/**
* The open library folder as a home-relative path ("Music/…"), or null at the root — the vocabulary the
* panels already speak, so reading the URL costs them nothing. Each panel calls this itself; they never
* tell each other where they are.
*/
export const useMusicCwd = (): string | null => {
const rel = useSearchParams()[0].get(MUSIC_PATH_PARAM)?.trim() ?? '';
return rel ? `${MUSIC_ROOT}/${rel}` : null;
};
@@ -1,25 +0,0 @@
import { usePanelChannel } from 'hooks/usePanelChannel';
export const MUSIC_LYRICS_CHANNEL = 'music:lyrics';
const STORAGE_KEY = 'music.lyrics';
// Read once, at import: the play dock re-renders every animation frame, and this is its initial value.
const initialOpen = localStorage.getItem(STORAGE_KEY) === '1';
/**
* Whether the lyrics panel is open. Shared by the two microphone buttons — the one in the play dock and
* the one on the album header — which are the same switch shown twice, so it lives in a channel rather
* than in either component. Seeded from localStorage so the choice survives a reload.
*/
export const useLyricsOpen = () => {
const [open, setOpen] = usePanelChannel<boolean>(MUSIC_LYRICS_CHANNEL, initialOpen);
// Deliberately not a functional update: useGlobal applies those to the render-time snapshot, and `open`
// is that snapshot anyway.
const toggleLyrics = () => {
localStorage.setItem(STORAGE_KEY, open ? '0' : '1');
setOpen(!open);
};
return [open, toggleLyrics] as const;
};
@@ -1,52 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { FavoriteKind, GroupedFavorites } from './shared';
const KEY = ['music', 'favorites'] as const;
const EMPTY: GroupedFavorites = { tracks: [], albums: [], artists: [] };
const groupOf = (kind: FavoriteKind): keyof GroupedFavorites =>
kind === 'track' ? 'tracks' : kind === 'album' ? 'albums' : 'artists';
/**
* The user's music favorites (tracks / albums / artists) for the /music workspace, backed by the
* platform's `/api/music/favorites`. One shared react-query cache, so every heart reflects the same
* state; toggling is optimistic (flips instantly, rolls back on failure).
*/
export function useMusicFavorites() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const { data } = useQuery({
queryKey: KEY,
queryFn: () => get<GroupedFavorites>('/music/favorites'),
staleTime: 60_000,
});
const mutation = useMutation({
mutationFn: ({ on, kind, key }: { on: boolean; kind: FavoriteKind; key: string }) =>
on
? post('/music/favorites', { kind, key })
: del(`/music/favorites?kind=${encodeURIComponent(kind)}&key=${encodeURIComponent(key)}`),
onMutate: async ({ on, kind, key }) => {
await qc.cancelQueries({ queryKey: KEY });
const prev = qc.getQueryData<GroupedFavorites>(KEY) ?? EMPTY;
const g = groupOf(kind);
qc.setQueryData<GroupedFavorites>(KEY, {
...prev,
[g]: on ? [key, ...prev[g].filter((k) => k !== key)] : prev[g].filter((k) => k !== key),
});
return { prev };
},
onError: (_e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(KEY, ctx.prev);
},
});
const isFavorite = (kind: FavoriteKind, key: string) => (data ?? EMPTY)[groupOf(kind)].includes(key);
const toggle = (kind: FavoriteKind, key: string) => {
if (!key) return;
mutation.mutate({ on: !isFavorite(kind, key), kind, key });
};
return { favorites: data ?? EMPTY, isFavorite, toggle };
}
@@ -1,47 +0,0 @@
import { useGlobal } from 'hooks/useGlobal';
// App-wide music player state (react-query-backed via useGlobal, so it's shared across the whole app and
// survives route changes). The audio element itself lives in MusicPlayerHost (mounted once in the
// persistent DashboardLayout); this hook is the control surface any component uses to drive it.
export type PlayerTrack = {
albumRel: string; // album path relative to the Music root (for stream + cover URLs)
file: string; // track filename within the album folder
title?: string;
artist?: string;
};
export type MusicPlayerState = {
queue: PlayerTrack[];
index: number;
playing: boolean;
};
const INITIAL: MusicPlayerState = { queue: [], index: 0, playing: false };
export function useMusicPlayer() {
const [state, setState] = useGlobal<MusicPlayerState>('MUSIC_PLAYER', INITIAL);
const playQueue = (queue: PlayerTrack[], index = 0) =>
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: true });
// Like playQueue but paused — for restoring a saved "currently playing" on load without auto-playing
// (browsers block autoplay on reload anyway; the user resumes with a click).
const loadQueue = (queue: PlayerTrack[], index = 0) =>
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: false });
const toggle = () => setState((s) => ({ ...s, playing: !s.playing }));
const setPlaying = (playing: boolean) => setState((s) => ({ ...s, playing }));
// Mirror an engine-driven natural advance into the index WITHOUT restarting playback (the audio engine
// has already transitioned to the next track gaplessly; this only updates the UI/highlight).
const syncIndex = (index: number) => setState((s) => ({ ...s, index }));
const jump = (index: number) =>
setState((s) => ({ ...s, index: Math.max(0, Math.min(index, s.queue.length - 1)), playing: true }));
const next = () =>
setState((s) =>
s.index < s.queue.length - 1 ? { ...s, index: s.index + 1, playing: true } : { ...s, playing: false },
);
const prev = () => setState((s) => (s.index > 0 ? { ...s, index: s.index - 1, playing: true } : s));
const close = () => setState(INITIAL);
const current = state.queue[state.index];
return { ...state, current, playQueue, loadQueue, toggle, setPlaying, syncIndex, jump, next, prev, close };
}
+3 -4
View File
@@ -5,7 +5,6 @@ export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
export * from './AppRegistry';
export * from './WidgetRegistry';
export * from './MusicPlayer';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
export {
@@ -111,9 +110,9 @@ export {
ARCHIVE_EXTS,
} from './apps/FileViewer';
export type { FileType } from './apps/FileViewer';
// The scrubber, shared by the FileViewer's audio/video renderers, the global player bar and the /music
// panels in `plugins/music/`. On the barrel rather than reached for by subpath because the package's
// `"./*"` export maps to `.ts` only, and this is a `.tsx`.
// The scrubber, shared by the FileViewer's audio/video renderers and the /music panels in
// `plugins/music/`. On the barrel rather than reached for by subpath because the package's `"./*"`
// export maps to `.ts` only, and this is a `.tsx`.
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';