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