Skip to content

Events & callbacks

The player exposes its lifecycle two ways. Pick whichever fits how you wired the player up:

  • on* constructor callbacks — passed as options when you build the player. Best when you own the new WaveformPlayer(...) call and want a single handler per instance.
  • waveformplayer:* DOM events — bubbling CustomEvents dispatched on the player’s container element. Best for declarative (data-waveform-player) players you never constructed by hand, for attaching/detaching listeners after init, or for delegating from an ancestor.

Both paths fire from the same internal points, so a given moment (say, playback starting) triggers both the onPlay callback and the waveformplayer:play event. Use one, the other, or both.

Moment Callback DOM event
Ready (post-construct) — waveformplayer:ready
Waveform drawn onLoad —
Play onPlay waveformplayer:play
Pause onPause waveformplayer:pause
Progress tick onTimeUpdate waveformplayer:timeupdate
Track ended onEnd waveformplayer:ended
Load/decode/audio error onError —
Destroyed — waveformplayer:destroy
Play requested (external) — waveformplayer:request-play
Pause requested (external) — waveformplayer:request-pause
Seek requested (external) — waveformplayer:request-seek

Note the asymmetry: onLoad / onError (and the Media Session onNextTrack / onPreviousTrack) have no DOM-event twin, and ready / destroy / request-* have no callback twin.

Pass these as options. Each defaults to null. They are plain functions invoked with positional arguments — there is no event object and nothing to preventDefault().

Callback Signature Fires when
onLoad (player) => void A track’s waveform has finished drawing (after peaks are fetched/generated and rendered).
onPlay (player) => void Playback starts — both self and external modes.
onPause (player) => void Playback pauses — both modes. Runs once when a track ends, before onEnd (1.28.0+; self mode used to run it twice there).
onEnd (player) => void The track reaches its end. In external mode this is synthesized when progress reaches >= 1.
onError (error, player) => void A load, decode, or audio error occurs. This is the single funnel for surfaced errors.
onTimeUpdate (currentTime, duration, player) => void On every progress tick. currentTime and duration are in seconds. Same argument order in both modes.
onNextTrack (player) => void The Media Session “next track” control is used (lock screen, headset). Setting it lights up that button.
onPreviousTrack (player) => void The Media Session “previous track” control is used. Setting it lights up that button.
import WaveformPlayer from '@arraypress/waveform-player';
import '@arraypress/waveform-player/dist/waveform-player.css';
const player = new WaveformPlayer('#player', {
url: '/audio/track.mp3',
onLoad: (p) => {
console.log('ready to play:', p.options.title);
},
onPlay: (p) => updateUI('playing'),
onPause: (p) => updateUI('paused'),
onTimeUpdate: (currentTime, duration, p) => {
progressEl.textContent = `${currentTime.toFixed(1)} / ${duration.toFixed(1)}s`;
},
onEnd: (p) => {
console.log('finished:', p.options.url);
playNext();
},
onError: (error, p) => {
console.error('playback failed:', error);
},
});

Every event is a bubbling CustomEvent dispatched on the player’s container (player.container). Read the payload from event.detail. detail.player is always the originating WaveformPlayer instance, and detail.url is the current track URL.

Event detail shape Cancelable Modes
waveformplayer:ready { player, url } no both
waveformplayer:play { player, url } no both
waveformplayer:pause { player, url } no both
waveformplayer:timeupdate { player, currentTime, duration, progress, url } no both
waveformplayer:ended { player, url, currentTime, duration } no both
waveformplayer:destroy { player, url } no both
waveformplayer:request-play track detail (see below) yes external only
waveformplayer:request-pause track detail yes external only
waveformplayer:request-seek track detail + { percent } yes external only

In the timeupdate detail, progress is a 0..1 fraction of the track (currentTime / duration), handy for driving a progress bar without dividing yourself. The ended detail reports currentTime equal to duration.

In self mode timeupdate (and onTimeUpdate) is emitted once per animation frame. Browsers stop the frame loop in background tabs while the audio plays on, so since 1.28.0 the native <audio> timeupdate takes over whenever the loop has been quiet for 500ms — progress keeps flowing (at the browser’s slower rate) in a hidden tab. A self-mode track’s end also emits exactly one waveformplayer:pause.

const el = player.container;
el.addEventListener('waveformplayer:ready', (e) => {
console.log('ready:', e.detail.url);
});
el.addEventListener('waveformplayer:timeupdate', (e) => {
const { currentTime, duration, progress } = e.detail;
bar.style.width = `${progress * 100}%`;
});
el.addEventListener('waveformplayer:ended', (e) => {
console.log('ended at', e.detail.currentTime, 'of', e.detail.duration);
});

Because the events bubble, a single delegated listener can cover many players — including declarative ones you never constructed:

// One listener for every player on the page, now or later.
document.addEventListener('waveformplayer:play', (e) => {
analytics.track('audio_play', {
url: e.detail.url,
id: e.detail.player.id,
});
});

destroy() emits waveformplayer:destroy first, before tearing anything down, so listeners get a chance to release references (the player, the element, cached detail objects) before the container is emptied and listeners are aborted. It does not emit request-pause (1.28.0+), so tearing down an external-mode player never pauses its controller.

player.container.addEventListener('waveformplayer:destroy', (e) => {
cleanupFor(e.detail.player.id);
});

In audioMode: 'external' the player owns no <audio> element — it is a visualization surface driven by an external clock via setPlayingState() and setProgress(). When the user interacts with it (clicks the play button, clicks the canvas, or seeks with the keyboard), the player can’t act on its own, so it asks the controller by dispatching a cancelable request event.

These three events fire only in external mode:

Event Extra detail Triggered by
waveformplayer:request-play — play() / togglePlay() while paused
waveformplayer:request-pause — pause() / togglePlay() while playing
waveformplayer:request-seek { percent } (0..1) canvas click or keyboard slider seek

All three carry the full track detail object (the seek event adds percent):

{
url: string,
title: string | null,
artist: string | null,
artwork: string | null,
markers: Array<{ time: number, label: string, color?: string }>,
waveform: number[] | string | null,
id: string,
player: WaveformPlayer
}

This shape mirrors the input that WaveformBar.play() expects, so a controller can forward the detail straight through.

Call event.preventDefault() to veto the player’s default reaction:

  • request-play / request-pause: preventDefault() tells the player the request was refused, so it leaves the singlePlay bookkeeping alone — a vetoed request-play doesn’t claim WaveformPlayer.currentlyPlaying, and a vetoed request-pause keeps it (1.28.0+; before that, request-pause cleared it regardless). Neither event flips the play/pause visual, vetoed or not — that only changes when your controller calls setPlayingState().
  • request-seek: without preventDefault() the local progress overlay advances optimistically to percent so the canvas repaints immediately. Call preventDefault() if you’d rather wait and drive the position yourself via setProgress().
const viz = new WaveformPlayer('#viz', {
url: '/audio/track.mp3',
audioMode: 'external',
waveform: peaks, // pre-computed; skips Web Audio decode
});
const audio = document.querySelector('#my-audio');
viz.container.addEventListener('waveformplayer:request-play', (e) => {
e.preventDefault(); // we own playback
audio.play();
});
viz.container.addEventListener('waveformplayer:request-pause', (e) => {
e.preventDefault();
audio.pause();
});
viz.container.addEventListener('waveformplayer:request-seek', (e) => {
// Let the overlay move optimistically (no preventDefault),
// then sync the real clock.
audio.currentTime = e.detail.percent * audio.duration;
});
// Pump the visuals from the real <audio> clock:
audio.addEventListener('play', () => viz.setPlayingState(true));
audio.addEventListener('pause', () => viz.setPlayingState(false));
audio.addEventListener('timeupdate', () =>
viz.setProgress(audio.currentTime, audio.duration)
);

Reach for callbacks when…

You construct the player in JS and want one handler bound for its whole life. They’re terser and the player is the (usually only) argument.

Reach for DOM events when…

The player is declarative (data-waveform-player), you need to add/remove listeners dynamically, you want to delegate from an ancestor, or you need to veto an external-mode request with preventDefault().

All document- and element-level listeners the player registers internally are tied to a single AbortController, so destroy() removes every one of them in a single step (and disconnects the ResizeObserver). You are responsible only for listeners you added with addEventListener — remove those yourself, ideally in response to waveformplayer:destroy.

const onEnded = (e) => playNext(e.detail.player);
player.container.addEventListener('waveformplayer:ended', onEnded);
// later…
player.container.addEventListener('waveformplayer:destroy', () => {
player.container.removeEventListener('waveformplayer:ended', onEnded);
});