Bart Veneman

Creator of Project Wallace, analytics for CSS. <noscript>-fanboy. Serverless clearfix engineer. I can lift node_modules twice my own weight.

Audio player in plain JavaScript with State Machine

The <audio> element is a tiny, yet powerful thing. As an exercise to improve my knowledge about State Machines with XState, I thought it would be fun to implement a State Machine for an audio player.

See the Pen Audio player in vanilla JS with State Machine (xstate) by Bart Veneman (@bartveneman) on CodePen.

Features

The State Machine

In it's most basic form, the state machine looks like this:

import { createMachine, interpret, assign } from "xstate";

const machine = createMachine(
{
initial: "paused",
context: {
currentTime: 0, // (s)
durationTime: 0, // (s)
},
on: {
durationchange: {
actions: [
assign({
durationTime: (context, event) => event.target.duration,
}),
"renderDurationTime",
],
},
timeupdate: {
actions: [
assign({
currentTime: (context, event) => event.target.currentTime,
}),
"renderCurrentTime",
"updateScrubber",
],
},
SCRUB: {
actions: [
assign({
currentTime: (context, event) => event.value * context.durationTime,
}),
"updateAudioCurrentTime",
"renderCurrentTime",
"updateScrubber",
],
},
},
states: {
paused: {
on: {
TOGGLE_PLAY: {
target: "playing",
actions: ["playAudio"],
},
},
},
playing: {
on: {
TOGGLE_PLAY: {
target: "paused",
actions: ["pauseAudio"],
},
},
},
},
},
{
actions: {
playAudio: () => {
audio.play();
playToggle.dataset.state = "playing";
},
pauseAudio: () => {
audio.pause();
playToggle.dataset.state = "paused";
},
updateAudioCurrentTime: (context, event) => {
audio.currentTime = context.currentTime;
},
renderCurrentTime: (context) => {
currentTime.innerText = formatDuration(context.currentTime);
},
renderDurationTime: (context) => {
durationTime.innerText = formatDuration(context.durationTime);
},
updateScrubber: (context) => {
// update position of the <input type="range"> element
scrubber.value = context.currentTime / context.durationTime;
// update position of the <progress> element
progress.value = context.currentTime / context.durationTime;
},
},
}
);

And here is what the XState visualizer makes of it:

XState visualisation of the audio player State Machine

You can see that there are only two possible states, but there is a list of events that we need to handle in order to make this work.

Sending and handling events

This app creates and handles a handful of events. The State Machine than receives and handles these events to update the audio playhead, playbacktime and total time. As you can see, all events are handles regardless of the current state. That's because it doesn't matter whether the app is currently playing or not to update the internal timers.

const machine = createMachine(/* machine definition */);
const service = interpret(machine).start();

audio.addEventListener("timeupdate", service.send);
audio.addEventListener("durationchange", service.send);

// A click of the play/pause <button>
playToggle.addEventListener("click", (event) => {
service.send({ type: "TOGGLE_PLAY" });
});

// User changed the audio playhead with the <input type="range>
scrubber.addEventListener("input", (event) => {
service.send({
type: "SCRUB",
value: parseFloat(event.target.value),
});
});

Toggling play and pause

The pause and play events are the most basic events. They both toggle the current playing or paused state. Sending PAUSE to a playing machine does two things, as can be seen in the state machine: play/pause the <audio> element and toggle the data-state of the play/pause <button>.

Receiving audio events

The audio element comes with a suprising number of events that it can emit. For simplicity, these are the ones that the audio player will act upon:

Scrubber events

The final event that needs to be handled is the 'scrubber' event. That's the event emitted by the range input whenever it's value changes, meaning that the user changed the position of the audio playhead. This value is between 0 and 1, as dictated in the HTML. The state machine will calculate the new currentTime of the player based on this number.

No framework

At first I was tempted to use Svelte or React to implement this player, but here's the thing: XState makes events, actions and transitions so explicit, I can easily update the DOM myself whenever I need to. Look at the following actions part of the state machine. No client-side framework will make this any easier (for me, at least), but then again, this is a very small app.

const machine = createMachine({
initial: "paused",
states: {
playing: {
/* ... */
},
paused: {
/* ... */
},
},
actions: {
playAudio: () => {
audio.play();
playToggle.dataset.state = "playing";
},
pauseAudio: () => {
audio.pause();
playToggle.dataset.state = "paused";
},
updateAudioCurrentTime: (context, event) => {
audio.currentTime = context.currentTime;
},
renderCurrentTime: (context) => {
currentTime.innerText = formatDuration(context.currentTime);
},
renderDurationTime: (context) => {
durationTime.innerText = formatDuration(context.durationTime);
},
updateScrubber: (context) => {
// update position of the <input type="range"> element
scrubber.value = context.currentTime / context.durationTime;
// update position of the <progress> element
progress.value = context.currentTime / context.durationTime;
},
},
});

Credits