web /music: sample-accurate gapless playback (Web Audio)

The player used a single <audio> element and advanced by swapping .src, which
re-fetches + re-buffers the next file — an audible gap between tracks. For a
continuous DJ mix (silence already trimmed at the edges) that's the whole
problem. Replace the <audio> element with a Web Audio engine that decodes each
track to an AudioBuffer and schedules the NEXT track's source to start() at the
exact AudioContext time the current track ends → sample-accurate, zero gap on
auto-advance.

- gapless-engine.ts (new): AudioContext + gain, LRU-capped decoded-buffer cache,
  fetch-whole-file → decodeAudioData, boundary scheduling, seek/skip/play-pause
  (pause = ctx.suspend so the clock + scheduled next freeze together), a
  generation counter to invalidate stale onended/async, and a gesture unlock for
  autoplay policy. Callbacks: onIndex/onTime/onEndOfQueue/onLoadingChange.
- MusicPlayerHost.tsx: drives the engine instead of an <audio> element. React
  keeps the queue/index (useMusicPlayer); user actions (new album, jump, prev/
  next) command the engine, and the engine's own natural advance mirrors back
  via syncIndex WITHOUT restarting playback (that's what keeps the seam gapless).
  Preserves restore/persist/heartbeat/album-nav/volume/heart; adds a decode
  spinner on the play button (startup/skip has fetch+decode latency by nature).
- useMusicPlayer.ts: syncIndex() — set index without touching `playing`.

Trade-off (chosen deliberately over near-gapless preloading): true gapless
needs the whole next file decoded to PCM ahead of time (~200MB per 10-min
track), so the buffer cache is capped at 3. Verified the scheduler state
machine with a mocked AudioContext: next track scheduled at the current's exact
end sample, advance/promote/seek/skip/end-of-queue all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 12:18:09 +00:00
co-authored by Claude Opus 4.8
parent e2c9905885
commit 0ef8e9fbd2
3 changed files with 576 additions and 158 deletions
@@ -2,14 +2,28 @@ import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
import { useSeekBar, SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2 } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
import { MUSIC_ROOT, MUSIC_CWD_CHANNEL, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
import {
MUSIC_ROOT,
MUSIC_CWD_CHANNEL,
sortTracks,
trackHomePath,
type AlbumMeta,
type NowPlaying,
} from '../apps/Music/shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
import { GaplessEngine, type EngineTrack } from './gapless-engine';
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single <audio>
// element and the site-wide play dock — playback survives navigation between routes.
// 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';
const fmt = (s: number): string =>
@@ -19,22 +33,25 @@ export const MusicPlayerHost = () => {
const { token, get, put } = useClient();
const navigate = useNavigate();
const [, setCwd] = usePanelChannel<string | null>(MUSIC_CWD_CHANNEL, null);
const { current, index, queue, playing, toggle, next, prev, setPlaying, close, loadQueue } = useMusicPlayer();
const audioRef = useRef<HTMLAudioElement>(null);
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 { barRef, onSeekDown } = useSeekBar(audioRef, duration);
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
// Latest position/duration in refs so persist reads fresh values without re-subscribing. seekToRef
// holds a pending seek (restore); isRestoringRef suppresses persist during the restore load so it
// doesn't clobber the saved position with 0; restoredRef makes restore run once.
// 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);
@@ -42,6 +59,13 @@ export const MusicPlayerHost = () => {
const restoredRef = useRef(false);
const isRestoringRef = useRef(false);
const seekToRef = useRef<number | null>(null);
const engineIndexRef = useRef(0);
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;
@@ -56,6 +80,36 @@ export const MusicPlayerHost = () => {
}).catch(() => {});
};
// ── Engine lifecycle (mounted once) ──
useEffect(() => {
const engine = new GaplessEngine({
onTime: (pos, dur) => {
positionRef.current = pos;
durationRef.current = dur;
setPosition(pos);
setDuration(dur);
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
syncIndex(i);
},
onEndOfQueue: () => setPlaying(false),
onLoadingChange: setLoading,
});
engineRef.current = engine;
engine.setVolume(muted ? 0 : volume);
// Resume the AudioContext on the first user gesture (browser autoplay policy).
const unlock = () => engine.unlock();
document.addEventListener('pointerdown', unlock);
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).
useEffect(() => {
@@ -69,8 +123,16 @@ export const MusicPlayerHost = () => {
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));
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);
@@ -78,6 +140,42 @@ export const MusicPlayerHost = () => {
// 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;
@@ -93,35 +191,6 @@ export const MusicPlayerHost = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, trackKey]);
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)}`);
// Load the current track when it changes.
useEffect(() => {
const a = audioRef.current;
if (!a || !current) return;
a.src = streamUrl(current);
setPosition(0);
if (playing) void a.play().catch(() => setPlaying(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackKey]);
// Play/pause without reloading the source.
useEffect(() => {
const a = audioRef.current;
if (!a || !current) return;
if (playing) void a.play().catch(() => {});
else a.pause();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing]);
// Apply volume/mute to the element.
useEffect(() => {
if (audioRef.current) audioRef.current.volume = muted ? 0 : volume;
}, [volume, muted, trackKey]);
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
@@ -129,6 +198,29 @@ export const MusicPlayerHost = () => {
localStorage.setItem('music.volume', String(v));
};
// 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));
const sec = pct * duration;
setPosition(sec);
engineRef.current?.seek(sec);
};
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;
@@ -139,122 +231,118 @@ export const MusicPlayerHost = () => {
navigate('/music');
};
if (!current) return null;
return (
<>
<audio
ref={audioRef}
preload="metadata"
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => {
setDuration(e.currentTarget.duration);
// Apply a pending restore seek once the track's duration is known, then re-enable persist.
if (seekToRef.current != null) {
e.currentTarget.currentTime = seekToRef.current;
setPosition(seekToRef.current);
seekToRef.current = null;
}
isRestoringRef.current = false;
}}
onEnded={() => next()}
/>
{current && (
<div className="fixed inset-x-0 bottom-0 z-50 flex items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
{/* cover + info — click to open this album in /music */}
<button
type="button"
onClick={openCurrentAlbum}
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>
</button>
{/* 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"
>
{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">
{fmt(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">
{fmt(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>
<MusicHeart
kind="track"
favKey={trackHomePath(current.albumRel, current.file)}
size={18}
className="shrink-0 p-1.5"
<div className="fixed inset-x-0 bottom-0 z-50 flex items-center gap-3 border-t border-border bg-card/95 px-4 py-2 backdrop-blur">
{/* cover + info — click to open this album in /music */}
<button
type="button"
onClick={openCurrentAlbum}
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';
}}
/>
<button type="button" onClick={close} className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground">
<X size={16} />
</button>
</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>
</button>
{/* 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">
{fmt(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">
{fmt(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>
<MusicHeart
kind="track"
favKey={trackHomePath(current.albumRel, current.file)}
size={18}
className="shrink-0 p-1.5"
/>
<button
type="button"
onClick={close}
className="shrink-0 cursor-pointer p-1.5 text-muted-foreground hover:text-foreground"
>
<X size={16} />
</button>
</div>
);
};
@@ -0,0 +1,325 @@
// 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;
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
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). */
private async begin(gen: number): Promise<void> {
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);
}
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);
src.onended = () => {
if (gen === this.gen) this.advance();
};
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) {
try {
this.nxt.onended = null;
this.nxt.stop();
} catch {
/* not started */
}
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);
src.onended = () => {
if (gen === this.gen) this.advance();
};
this.nxt = src;
this.nextStartAt = boundary;
}
/** 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);
}
}
private stopSources(): void {
for (const s of [this.cur, this.nxt]) {
if (!s) continue;
try {
s.onended = null;
s.stop();
} catch {
/* not started */
}
try {
s.disconnect();
} catch {
/* already disconnected */
}
}
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);
}
}
@@ -33,13 +33,18 @@ export function useMusicPlayer() {
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 }));
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, jump, next, prev, close };
return { ...state, current, playQueue, loadQueue, toggle, setPlaying, syncIndex, jump, next, prev, close };
}