Building a minimal audio player
Last week I needed to build an audio player for the company I work for. I bet there are already a ton of NPM packages out there that are accomplishing the same thing, but since the UI of the audio player needed to match our existing UI, and the effort to create a custom one would be relatively small, I decided to write this component myself. So here we are... coding time!
What are we going to build?
Oh, by the way, the audio you're hearing is from a podcast called The Undefined Podcast. Well worth a listen 👍🏼.
What do we want to build?
First, let's take a look at what we want to build. We want...
- our player to play audio based on a url we provide via props
- to toggle between playback states (pause / play)
- to see the current position in the audio-file (min:sec)
- to see the total duration of the audio-file (min:sec)
- a bar which gives some sense of progress
- a circle on that bar that allows us to go to any position
- our player to show a loading state
- our player to show a buffering state
Now that we've formulated the player's requirements, we kind of know how the component will look from the outside:
<AudioPlayer url="link-to-audio.mp3" />
Right, so basically our AudioPlayer is responsible for all the behavior and interaction we're about to implement. Of course, we can extend it's flexibility by optionally passing control to the parent for instance, but for now, we got enough work on our plates as it is.
Let's go!
Structure
The UI of the player will consist of two main components:
- PlaybackButton - for toggling between play and pause
- TimeBar - for showing time related info / interaction
Ideally, I want these UI components to be as dumb as possible. They just have to do their jobs. As far as I'm concerned they don't even have to know they're dealing with audio. This way, when we're building a video player in a few weeks, it's very likely we can re-use the same components we've built today.
But obviously the logic needs to go somewhere, preferably a hook, so that we can hopefully use it somewhere else in the future too.
A quick draft:
function AudioPlayer({ url }) {
const audioLogicStuff = useAudio(url);
return (
<>
<PlaybackButton />
<TimeBar />
</>
);
}
PlaybackButton
This is the least interesting part I guess. Basically it's just another button we can click on, indicating a playbackState by means of an icon. Sorry Button, nothing personal!
TimeBar (part 1)
Alright, this will be a bit harder though ;)
Let's start with the props to get some sense:
<TimeBar
currentTime={0} // how much seconds have we played since the start
duration={1000} // total duration in seconds
progress={50} // current position within audio-file (percentage 0-100)
isSeeking={true} // did we just jump in time and is audio buffering
setTime={() => {}} // function to set time location within audio
/>
Next we need to layout the structure of our elements:
function TimeBar() {
return (
<div className="timebar">
<div className="timebar-bar" />
<div className="timebar-circle" />
<div className="timebar-time-info">
<div>{isSeeking ? "buffering..." : "00:00"}</div>
<div>03:11</div>
</div>
</div>
);
}
We need to think about how the time related data we'll get from props, will affect the styling of our elements.
Let's begin with timebar-bar
. We want a grey background in general, and a colored area up to the point where the current location is (progress in audio file). We could use two div's to achieve this, or we could use a linear-gradient background. Let's choose the latter, because... well I just like gradients 🙂. Also let's write a small utility-function that generates a style based on a certain progress:
function getBarStyle(progress) {
const GREY = "#737373";
const FILL = "#3d858c";
return {
background: `linear-gradient(to right, ${FILL} 0%, ${FILL} ${progress}%, ${GREY} ${progress}%, ${GREY} 100%)`,
};
}
We might as well repeat this process for the timebar-circle
:
function getCircleStyle(progress) {
return {
left: `${progress}%`,
};
}
What's left are the time displays; one for the current time, and one for the total duration. The problem is that we will receive this data in seconds, though we want to display them in a 'mm:ss' format. So, we need some kind of function to transform those values:
// 90 => "01:30"
function formatTime(seconds) {
return (
[
Math.floor(seconds / 60), // minutes
Math.floor(seconds % 60), // remaining seconds
]
.map(x => x.toString())
// we want double digits, prepend a "0"
// if necessary
.map(x => (x.length === 1 ? `0${x}` : x))
// join the result with a colon
.join(":")
);
}
If we apply these functions to our existing code:
function TimeBar({ progress, currentTime, duration, isSeeking }) {
return (
<div className="timebar">
<div className="timebar-bar" style={getBarStyle(progress)} />
<div className="timebar-circle" style={getCircleStyle(progress)} />
<div className="timebar-time-info">
<div>{isSeeking ? "buffering..." : formatTime(currentTime)}</div>
<div>{formatTime(duration)}</div>
</div>
</div>
);
}
Great! We now got ourselves a component which shows us continuously relevant time info. We've forgotten one thing though. We need a way to jump back and forth in time. We'll come back at this in a second...
The logic
In order to play audio we need to use the <audio />
element. For more background I recommend to read through the docs on MDN. I'm going to give you a small overview of the bits we're going to use:
<audio
src={"link-to-audio.mp3"}
// calls when the current location in our audio
// file changes
onTimeUpdate={() => {}}
// audio is ready to be played
onLoadedData={() => {}}
// we've just jumped in time and now the audio
// is seeking it's new position (sort of buffering)
onSeeking={() => setSeeking(true)}
// found it's new position
onSeeked={() => setSeeking(false)}
/>
There are a couple of methods available on a HTMLAudioElement
as well. The ones we're interested in, are play()
and pause()
.
Ok, we now know how to retrieve the desired data, and how to alter it's behavior (play/pause/change url). Next, we need to setup some state:
function useAudio(url) {
const [currentTime, setCurrentTime] = React.useState(0);
const [duration, setDuration] = React.useState(0);
const [playbackStatus, setPlaybackStatus] = React.useState("pause");
const [isLoading, setLoading] = React.useState(true);
const [isSeeking, setSeeking] = React.useState(false);
}
Whooaah, that are a lot of useState
's man! Jep, true, feel free to use useReducer
instead, but for me personally the state isn't complex enough to refactor into useReducer
. Anyway, let's connect it with the audio element. Yeah that's right, with hooks you can return anything you like really, so why not elements?
function useAudio(url) {
const audioRef = React.useRef(null);
const [currentTime, setCurrentTime] = React.useState(0);
const [duration, setDuration] = React.useState(0);
const [playbackStatus, setPlaybackStatus] = React.useState("pause");
const [isLoading, setLoading] = React.useState(true);
const [isSeeking, setSeeking] = React.useState(false);
// when url changes -> set loading state
React.useEffect(() => {
setLoading(true);
}, [url]);
return [
<audio
src={url}
// we don't want to actually display the
// browser's default element
hidden
ref={audioRef}
onLoadedData={() => {
setPlaybackStatus("pause");
setLoading(false);
setDuration(audioRef.current.duration);
}}
onSeeking={() => setSeeking(true)}
onSeeked={() => setSeeking(false)}
onTimeUpdate={() => {
// on update, retrieve currentTime from ref,
// store it in state
setCurrentTime(audioRef.current.currentTime);
}}
/>,
{
currentTime,
duration,
playbackStatus,
isSeeking,
isLoading,
progress: (currentTime / duration) * 100,
setTime: seconds => {
audioRef.current.currentTime = seconds;
},
togglePlaybackStatus: () => {
// there are nicer ways to handle
// 'play' and 'pause'. The thing is that Safari
// browsers only allow you to change audio's
// playback-state in direct response to an event
// https://medium.com/@curtisrobinson/how-to-auto-play-audio-in-safari-with-javascript-21d50b0a2765
if (playbackStatus === "play") {
audioRef.current.pause();
setPlaybackStatus("pause");
}
if (playbackStatus === "pause") {
audioRef.current.play();
setPlaybackStatus("play");
}
},
},
];
}
Let's zoom out a bit to see what we got so far:
function AudioPlayer({ url }) {
const [audioElement, audioProps] = useAudio(url);
return (
<div>
{audioElement}
{audioProps.isLoading ? (
<div>Loading...</div>
) : (
<div>
<PlaybackButton
onClick={audioProps.togglePlaybackStatus}
playbackStatus={audioProps.playbackStatus}
/>
<TimeBar
currentTime={audioProps.currentTime}
isSeeking={audioProps.isSeeking}
duration={audioProps.duration}
progress={audioProps.progress}
setTime={audioProps.setTime}
/>
</div>
)}
</div>
);
}
On to the finale!
TimeBar (part 2)
Remember we still need to implement the time jumping thing? Let's do it!
The way for the user to jump through time is by dragging the circle above the timebar. We want it to behave as follows:
- when a user drags the circle on the x-axis we want a visual update
- when the users stops dragging, we want to commit and set the new time
- while dragging we want to ignore any incoming time-updates (otherwise we get conflicting styles, which will result in the circle jumping all over the place)
I don't want to build a whole dragging mechanism myself. Fortunately we can use a library that's perfect for this scenario: react-use-gesture
. You read more about it on Github.
For updating the circle's style multiple times a second, I'm gonna use the library I created myself (direct-styled
) and the one we've discussed in my previous article. Let's dig in.
import * as React from "react";
import { useDrag } from "react-use-gesture";
import { directstyled } from "direct-styled";
function TimeBar({ duration, progress, currentTime, isSeeking, setTime }) {
// keep reference to the timebar-bar element
// we use it for measurement later on
const barRef = React.useRef(null);
// hooks for updating rapid changing styles
const [barStyle, setBarStyle] = useDirectStyle();
const [circleStyle, setCircleStyle] = useDirectStyle();
// keep track of wether or not to ignoreTimeUpdates
// while dragging === ignore
const [ignoreTimeUpdates, setIgnoreTimeUpdates] = React.useState(false);
function setStyles(progress) {
setCircleStyle(getCircleStyle(progress));
setBarStyle(getBarStyle(progress));
}
// bind contains a bundle of stuff we need to
// spread on the draggable element (circle)
const bind = useDrag(
// when user is dragging...
({ xy, first, last, event }) => {
// prevent default drag behavior
event.preventDefault();
// if user just started dragging...
if (first) {
setIgnoreTimeUpdates(true);
}
// calc new position and progress based
// on the user's position
const { seconds, progress } = getNewTimeProps(
barRef.current.getBoundingClientRect(),
xy[0],
duration
);
// if user stopped dragging
// commit time and restore regular time updates
if (last) {
setTime(seconds);
setIgnoreTimeUpdates(false);
return;
}
// update styles to get visual feedback
setStyles(progress);
},
// some options we need in order to use `event.preventDefault()`
{ event: { passive: false, capture: true } }
);
// when progress prop changes...
React.useEffect(() => {
// do nothing if we're currently dragging
if (ignoreTimeUpdates) {
return;
}
// otherwise, update accordingly
setStyles(progress);
}, [progress]);
return (
<div className={"timebar"}>
<directstyled.div ref={barRef} className="timebar-bar" style={barStyle} />
<directstyled.div
// spread useDrag's props
{...bind()}
className="timebar-circle"
style={circleStyle}
/>
<div className="timebar-time-info">
<div>{isSeeking ? "buffering..." : formatTime(currentTime)}</div>
<div>{formatTime(duration)}</div>
</div>
</div>
);
}
Ok, that was a lot of code at once. Hopefully my comments helped in some way. There's only one piece of the puzzle missing, and that's how we calculate a new currentTime (seconds) based on the user's drag position. For completeness, it involves these couple of lines right here:
const { seconds, progress } = getNewTimeProps(
barRef.current.getBoundingClientRect(),
xy[0],
duration
);
Let's take a look at this getNewTimeProps()
function:
// helper function that makes sure a number does not exceed
// a lower and upper range
const minMax = (min, max, value) => {
if (value > max) {
return max;
}
if (value < min) {
return min;
}
return value;
};
function getNewTimeProps(barRect, clientX, duration) {
// Get new seconds based on the user's position, bar's width,
// and the total duration.
// Takes into account the bar's offset relative to the screen
const seconds = minMax(
0,
duration,
Math.floor(((clientX - barRect.left) / barRect.width) * duration)
);
const progress = (seconds / duration) * 100;
return { seconds, progress };
}
Conclusion
There you have it: a minimal audio player written in ~150 lines of code! We've covered some interesting things along the way:
- working with the audio element
- putting logic in a hook for reusability / separation of concerns
- how we can utilize drag interaction to jump through time
- how we can update styles in a smooth and efficient way
Be sure to check out the complete code on CodeSandbox.
Thanks for reading! I hope you liked it, and stay tuned for more articles! And please, don't be afraid to leave your feedback below.