Install
pnpm add gaplessgapless.js is ESM only and ships its own types. Its one dependency, XState, is bundled.
Quick start
Create a Queue with a list of track URLs, then call play() from a click handler.
import Queue from 'gapless';
const player = new Queue({
tracks: [
'https://example.com/set1-01.mp3',
'https://example.com/set1-02.mp3',
'https://example.com/set1-03.mp3',
],
trackMetadata: [
{ title: 'Help on the Way' },
{ title: 'Slipknot!' },
{ title: 'Franklin\'s Tower' },
],
onProgress: (track) => renderProgress(track.currentTime, track.duration),
onStartNewTrack: (track) => renderNowPlaying(track.metadata?.title),
onEnded: () => console.log('Show over'),
});
playButton.addEventListener('click', () => player.togglePlayPause());
nextButton.addEventListener('click', () => player.next());gapless.js has no UI. It hands you plain data through callbacks and getters and you render it however you like.
How it works
An <audio> element can’t start a file on an exact sample. Calling play() on the next file when the first one fires ended always leaves an audible gap, even when the next file is fully buffered.
By default (HYBRID mode), each track starts playing right away through HTML5 audio so there’s no wait. Meanwhile gapless.js fetches and decodes the track with the Web Audio API. Once the decoded buffer is ready it swaps over mid-track without a click. Upcoming tracks are decoded ahead of time, and each one is scheduled on the AudioContext clock to start on the exact sample where the previous one ends.
If the next track isn’t decoded in time (a slow network, or you skipped ahead), it falls back to HTML5 audio for that handoff. You get a small gap instead of silence or an error, and it picks up Web Audio again once the decode finishes.
The queue and every track are driven by XState state machines, so playback is always in a known state no matter how fast users mash buttons.
Using with React
The Queue is a plain object. Create it once, read its getters into a snapshot whenever a callback fires, and subscribe with useSyncExternalStore.
use-player.ts
import { useEffect, useState, useSyncExternalStore } from 'react';
import Queue, { type TrackInfo } from 'gapless';
interface Snapshot {
current?: TrackInfo;
isPlaying: boolean;
}
class PlayerStore {
queue: Queue | null = null;
private snapshot: Snapshot = { isPlaying: false };
private listeners = new Set<() => void>();
constructor(private urls: string[]) {}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => void this.listeners.delete(fn);
};
getSnapshot = () => this.snapshot;
private sync = () => {
const q = this.queue;
if (!q) return;
this.snapshot = { current: q.currentTrack, isPlaying: q.isPlaying };
for (const fn of this.listeners) fn();
};
connect() {
const queue = new Queue({
tracks: this.urls,
onProgress: this.sync,
onStartNewTrack: this.sync,
onEnded: this.sync,
});
this.queue = queue;
return () => queue.destroy();
}
}
export function usePlayer(urls: string[]) {
const [store] = useState(() => new PlayerStore(urls));
useEffect(() => store.connect(), [store]);
const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
return { ...state, queue: store.queue };
}Create the Queue in an effect, not during render. It touches Audio and AudioContext, which don’t exist on the server. Call destroy() on unmount.
Options
Everything passed to new Queue(options). All of it is optional.
| Option | Type | Description |
|---|---|---|
tracks | string[] | Initial list of track URLs. |
trackMetadata | TrackMetadata[] | Per-track metadata, matched to tracks by index. |
volume | number | Initial volume, 0–1. Default 1. |
playbackRate | number | Initial playback rate, 0.25–4. Default 1. |
preloadNumTracks | number | How many tracks to preload ahead. Default 2, 0 disables. |
playbackMethod | 'HYBRID' | 'HTML5_ONLY' | 'WEBAUDIO_ONLY' | How audio is rendered. Default HYBRID. See Playback method. |
onProgress | (track: TrackInfo) => void | Called about 60 times a second while playing. |
onStartNewTrack | (track: TrackInfo) => void | Called whenever a different track becomes current, for any reason. |
onPlayNextTrack | (track: TrackInfo) => void | Called when the queue advances to the next track. |
onPlayPreviousTrack | (track: TrackInfo) => void | Called when the queue goes back a track. |
onEnded | () => void | Called when the last track finishes. |
onError | (error: Error) => void | Called on audio errors. |
onPlayBlocked | () => void | Called when the browser blocks playback (autoplay policy). |
onDebug | (msg: string) => void | Internal debug messages. Development only. |
Methods
| Method | Description |
|---|---|
play() | Start or resume playback. |
pause() | Pause playback. |
togglePlayPause() | Play if paused, pause if playing. |
next() | Go to the next track. Does nothing on the last track. |
previous() | Go to the previous track, or restart the current one if more than 8 seconds in. |
gotoTrack(index, playImmediately?, startTime?) | Jump to a track by index, optionally starting playback at startTime seconds. |
seek(time) | Seek within the current track, in seconds. |
setVolume(volume) | Set volume, clamped to 0–1. |
setPlaybackRate(rate) | Set playback rate, clamped to 0.25–4. Reschedules gapless transitions. |
addTrack(url, options?) | Append a track. See AddTrackOptions below. |
removeTrack(index) | Remove a track by index. |
resumeAudioContext() | Create and resume the shared AudioContext. Call from a user gesture. |
destroy() | Stop playback and release every resource. |
AddTrackOptions
player.addTrack('https://example.com/encore.mp3', {
// Skip the HEAD request used to resolve redirects, if the URL is already final.
skipHEAD: true,
metadata: { title: 'Ripple', artist: 'Grateful Dead' },
});Getters
Getters return fresh snapshots each time you read them. Nothing you get back is live.
| Getter | Type | Description |
|---|---|---|
currentTrack | TrackInfo | undefined | The current track. |
currentTrackIndex | number | Index of the current track. |
tracks | readonly TrackInfo[] | Every track in the queue. |
isPlaying | boolean | Whether the queue is playing. |
isPaused | boolean | Whether the queue is paused. |
volume | number | Current volume. |
playbackRate | number | Current playback rate. |
preloadNumTracks | number | Tracks to preload ahead. Also settable. |
TrackInfo
Every callback and getter gives you TrackInfo: plain data with no methods, safe to store in React state or send over postMessage.
interface TrackInfo {
index: number; // Position in the queue
currentTime: number; // Seconds
duration: number; // Seconds, NaN until loaded
isPlaying: boolean;
isPaused: boolean;
volume: number;
playbackRate: number;
trackUrl: string; // Resolved URL, after redirects
playbackType: 'HTML5' | 'WEBAUDIO'; // Which backend is making sound right now
webAudioLoadingState: 'NONE' | 'LOADING' | 'LOADED' | 'ERROR';
metadata?: TrackMetadata;
machineState: string; // Internal state, handy for debugging
}playbackType tells you whether the next handoff can be gapless. When the current track is WEBAUDIO and the next one has webAudioLoadingState: 'LOADED', it will be.
Metadata & Media Session
Metadata is yours to define. title, artist, album and artwork are passed to the Media Session API, so they show up on lock screens, in OS media controls and on hardware media keys. Play, pause, next, previous and seek from those controls work automatically. Any other fields come back to you on track.metadata.
interface TrackMetadata {
title?: string;
artist?: string;
album?: string;
artwork?: MediaImage[]; // [{ src, sizes, type }]
[key: string]: unknown; // Anything else you want to carry along
}Playback method
| Value | Behavior | Use it for |
|---|---|---|
HYBRID | Starts on HTML5 audio, switches to Web Audio once decoded. The default. | Remote files. Instant start and gapless transitions. |
HTML5_ONLY | HTML5 audio only. No gapless playback. | When Web Audio is unavailable or unwanted. |
WEBAUDIO_ONLY | Web Audio only. Waits for each file to download and decode before playing. | Small or local files where loading is instant. |
Preloading
gapless.js preloads and decodes the next two tracks by default. Lower it to save bandwidth and memory, raise it for flaky connections. You can change it at any time.
const player = new Queue({ tracks, preloadNumTracks: 1 });
player.preloadNumTracks = 0; // Disable preloading
player.preloadNumTracks = 3; // Preload 3 aheadDecoded audio lives in memory as raw PCM, roughly 10 MB per minute of stereo at 44.1 kHz. Keep this in mind for long tracks on mobile.
Playback rate
Speeds between 0.25× and 4× are supported, and gapless transitions are rescheduled to match.
const player = new Queue({ tracks, playbackRate: 1.5 });
player.setPlaybackRate(2);
player.playbackRate; // 2Autoplay
Browsers won’t start audio until the user has interacted with the page. Call play(), next() or gotoTrack(…, true) from a click or key handler and it just works. gapless.js creates its AudioContext lazily on that first gesture, so you won’t see console warnings on page load.
If you start playback outside a gesture and the browser refuses, onPlayBlocked fires. Show a play button and call play() again when it’s clicked.
const player = new Queue({
tracks,
onPlayBlocked: () => showPlayButton(),
});Migrating from v3
v4 is a complete rewrite. It’s ESM only, callbacks receive plain TrackInfo objects instead of Track instances, and Media Session support is built in.
| v3 | v4 |
|---|---|
import GaplessQueue from 'gapless.js' | import Queue from 'gapless' |
player.playNext() | player.next() |
player.playPrevious() | player.previous() |
player.resetCurrentTrack() | player.seek(0) |
player.disableWebAudio() | playbackMethod: 'HTML5_ONLY' |
player.nextTrack | player.tracks[player.currentTrackIndex + 1] |
track.completeState | TrackInfo |