Building an Audio Player With ReactJS
14 min read
Introduction
Yes! We're building an Audio Player using ReactJS! This is great opportunity for us to explore what the <audio />
HTML element has to offer. To get you more excited for this, the following is what we're building. Go ahead and explore it 😁.
Our Goal
By the end of this tutorial, we aim to build a functional audio player component with having at least the following features:
- Play/Pause the selected song
- Previous/Next track functions
- Change volume intensity
- Display track elapsed time and duration
- Show track progress
- Seek through track parts
- Show the playable (buffered) track parts
As you may already realized, we have a lot to go through so let's get started! 🚀
Building the AudioPlayer component
Initial Setup
The initial implementation is just a simple <audio />
HTML element:
import * as React from "react";
interface AudioPlayerProps {
currentSong?: { title: string; src: string };
songIndex: number;
songCount: number;
onNext: () => void;
onPrev: () => void;
}
export default function AudioPlayer(props: AudioPlayerProps) {
const { currentSong, songCount, songIndex, onNext, onPrev } = props;
const audioRef = React.useRef<HTMLAudioElement | null>(null);
return (
<div className="bg-slate-900 text-slate-400 p-3 relative">
{currentSong && (
<audio ref={audioRef} preload="metadata">
<source type="audio/mpeg" src={currentSong.src} />
</audio>
)}
</div>
);
}
A few points:
- Our
<AudioPlayer />
takes props one of which iscurrentSong
which has atitle
andsrc
properties; - We render an
<audio />
element only whencurrentSong
is truthy but it is hidden because we have not provided thecontrols
attribute; - We grab a reference of the rendered
<audio />
by passing aref
to it. - The attribute
preload
allows us to specify a preference for how the browser preloads the audio. In this case we are preloading the audio's metadata from which we can derive useful values like the track's duration.
Now, let us grab the track's duration:
export default function AudioPlayer(props: AudioPlayerProps) {
const { currentSong, songCount, songIndex, onNext, onPrev } = props;
const audioRef = React.useRef<HTMLAudioElement | null>(null);
// states
const [duration, setDuration] = React.useState(0);
return (
<div className="bg-slate-900 text-slate-400 p-3 relative">
{currentSong && (
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
)}
</div>
);
}
Okay, hold on! Before we go any further, let us take a look at the media loading process.
loadstart
- media loading has started and the browser is connecting to the media.durationchange
- fires when the duration of the media is already available.loadedmetadata
- fires when all media metadata has been loaded.loadeddata
- this event is fired when the first bit of media arrives.progress
- the event indicating that media downloading is still in progress.canplay
- fires when the media is ready to playable.canplaythrough
- this event lets us know that the media can be played all the way through.
Now, let us a state that trackes whether the audio is ready or not.
export default function AudioPlayer(props: AudioPlayerProps) {
/** truncated */
// states
const [duration, setDuration] = React.useState(0);
const [isReady, setIsReady] = React.useState(false);
return (
<div className="bg-slate-900 text-slate-400 p-3 relative">
{currentSong && (
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onCanPlay={(e) => {
setIsReady(true);
}}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
)}
</div>
);
}
Displaying the Track title
export default function AudioPlayer(props: AudioPlayerProps) {
/** truncated */
// states
const [duration, setDuration] = React.useState(0);
const [isReady, setIsReady] = React.useState(false);
return (
<div className="bg-slate-900 text-slate-400 p-3 relative">
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onCanPlay={(e) => {
setIsReady(true);
}}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
<div className="text-center mb-1">
<p className="text-slate-300 font-bold">
{currentSong?.title ?? "Select a song"}
</p>
<p className="text-xs">Singer Name</p>
</div>
</div>
);
}
Adding Play/Pause functions
Let's add a button that will allow us to toggle between the audio's playing and paused states.
import { MdPlayArrow, MdPause } from "react-icons/md";
import IconButton from "./components/IconButton";
export default function AudioPlayer(props: AudioPlayerProps) {
/** truncated */
// states
const [duration, setDuration] = React.useState(0);
const [isReady, setIsReady] = React.useState(false);
const [isPlaying, setIsPlaying] = React.useState(false);
const togglePlayPause = () => {
if (isPlaying) {
audioRef.current?.pause();
setIsPlaying(false);
} else {
audioRef.current?.play();
setIsPlaying(true);
}
};
return (
<div className="bg-slate-900 text-slate-400 p-3 relative">
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onCanPlay={(e) => {
setIsReady(true);
}}
onPlaying={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
<div className="text-center mb-1">
<p className="text-slate-300 font-bold">
{currentSong?.title ?? "Select a song"}
</p>
<p className="text-xs">Singer Name</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 items-center mt-4">
<div className="flex items-center gap-3 justify-self-center">
<IconButton
disabled={!isReady}
onClick={togglePlayPause}
aria-label={isPlaying ? "Pause" : "Play"}
size="lg"
>
{!isReady && currentSong ? (
<CgSpinner size={24} className="animate-spin" />
) : isPlaying ? (
<MdPause size={30} />
) : (
<MdPlayArrow size={30} />
)}
</IconButton>
</div>
</div>
</div>
);
}
A few points here:
- When
isReady
isfalse
we disable the button. - We defined a function called
togglePlayPause
to handle the play/pause logic. - When
isReady
is false we show a loading icon. - When
isPlaying
is true, we show a pause icon otherwise we show a play icon.
Adding a volume control
Let's now add a way to change the audio's volume. For this, we'll create a custom slider component which we'll call as VolumeInput
. It's implementation is as follows:
interface VolumeInputProps {
volume: number;
onVolumeChange: (volume: number) => void;
}
export default function VolumeInput(props: VolumeInputProps) {
const { volume, onVolumeChange } = props;
return (
<input
aria-label="volume"
name="volume"
type="range"
min={0}
step={0.05}
max={1}
value={volume}
className="w-[70px] m-0 h-2 rounded-full accent-cyan-600 bg-gray-700 appearance-none cursor-pointer"
onChange={(e) => {
onVolumeChange(e.currentTarget.valueAsNumber);
}}
/>
);
}
We then use the VolumeInput
component in our audio AudioPlayer
as follows:
import VolumeInput from "./components/VolumeInput";
export default function AudioPlayer(props: AudioPlayerProps) {
/** truncated */
// states
const [volume, setVolume] = React.useState(0.2); // set to 0.2, max is 1.0
const handleVolumeChange = (volumeValue: number) => {
if (!audioRef.current) return;
audioRef.current.volume = volumeValue;
setVolume(volumeValue);
};
return (
<div className="bg-slate-900 text-slate-400 p-3 relative">
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onCanPlay={(e) => {
e.currentTarget.volume = volume;
setIsReady(true);
}}
onPlaying={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
<div className="text-center mb-1">
<p className="text-slate-300 font-bold">
{currentSong?.title ?? "Select a song"}
</p>
<p className="text-xs">Singer Name</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 items-center mt-4">
<div className="flex items-center gap-3 justify-self-center">
<IconButton
disabled={!isReady}
onClick={togglePlayPause}
aria-label={isPlaying ? "Pause" : "Play"}
size="lg"
>
{!isReady && currentSong ? (
<CgSpinner size={24} className="animate-spin" />
) : isPlaying ? (
<MdPause size={30} />
) : (
<MdPlayArrow size={30} />
)}
</IconButton>
</div>
<div className="flex gap-3 items-center md:justify-self-end">
<VolumeInput volume={volume} onVolumeChange={handleVolumeChange} />
</div>
</div>
</div>
);
}
A few points here:
- We define another state for volume.
- We set the audio volume to our volume state value when the audio is ready to be played (
onCanPlay
). - We pass the volume state and the
handleVolumeChange
handler to theVolumeInput
component.
Adding the mute/unmute toggle
For this functionality, we just need a simple button to set the volume to either 0 or 1.
const handleMuteUnmute = () => {
if (!audioRef.current) return;
if (audioRef.current.volume !== 0) {
audioRef.current.volume = 0;
} else {
audioRef.current.volume = 1;
}
};
{
currentSong && (
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onPlaying={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={handleNext}
onCanPlay={(e) => {
e.currentTarget.volume = volume;
setIsReady(true);
}}
onTimeUpdate={(e) => {
setCurrrentProgress(e.currentTarget.currentTime);
handleBufferProgress(e);
}}
onVolumeChange={(e) => setVolume(e.currentTarget.volume)}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
);
}
<div className="flex gap-3 items-center md:justify-self-end">
<IconButton
intent="secondary"
size="sm"
onClick={handleMuteUnmute}
aria-label={volume === 0 ? "unmute" : "mute"}
>
{volume === 0 ? <MdVolumeOff size={20} /> : <MdVolumeUp size={20} />}
</IconButton>
<VolumeInput volume={volume} onVolumeChange={handleVolumeChange} />
</div>;
In the handleMuteUnmute
function, we are setting the volume directly to the audioRef
's current value's volume property. This fires the onVolumeChange
event of the audio element so here is where we set the value of the volume state so we can change the volume icon accordingly.
Creating the AudioProgressBar component
For this component, we want to be able to:
- Show track progress
- Seek through track parts
- Show the playable (buffered) track parts
In order to achieve this, we'll implement a new component.
import * as React from "react";
interface ProgressCSSProps extends React.CSSProperties {
"--progress-width": number;
"--buffered-width": number;
}
interface AudioProgressBarProps
extends React.ComponentPropsWithoutRef<"input"> {
duration: number;
currentProgress: number;
buffered: number;
}
export default function AudioProgressBar(props: AudioProgressBarProps) {
const { duration, currentProgress, buffered, ...rest } = props;
const progressBarWidth = isNaN(currentProgress / duration)
? 0
: currentProgress / duration;
const bufferedWidth = isNaN(buffered / duration) ? 0 : buffered / duration;
const progressStyles: ProgressCSSProps = {
"--progress-width": progressBarWidth,
"--buffered-width": bufferedWidth,
};
return (
<div className="absolute h-1 -top-[4px] left-0 right-0 group">
<input
type="range"
name="progress"
style={progressStyles}
min={0}
max={duration}
value={currentProgress}
{...rest}
/>
</div>
);
}
Alright, what's going on here? The component is just an input
of type range
. It's maximum value is the audio's duration. It's current value is the number of seconds that has elapsed since the audio was played. We show the actual progress and the buffered part by utilizing the input's ::before
and ::after
pseudo-elements and by giving such proper styles.
You might also notice that we're playing with some CSS variables here:
interface ProgressCSSProps extends React.CSSProperties {
"--progress-width": number;
"--buffered-width": number;
}
The reason for this is to dynamically scale the ::before
and ::after
pseudo-elements based on the actual progress and buffered values. Since we're doing this, we must add the ff. css to our global css file:
:root {
--progress-width: 0;
--buffered-width: 0;
}
.progress-bar::-webkit-slider-thumb {
z-index: 4;
position: relative;
}
.progress-bar::before {
transform: scaleX(var(--progress-width));
z-index: 3;
}
.progress-bar::after {
transform: scaleX(var(--buffered-width));
transform-origin: left;
z-index: 2;
}
Handling progress
We show the proper audio progress as well as the buffered part through the ff. implementation:
// states
const [currrentProgress, setCurrrentProgress] = React.useState(0);
const [buffered, setBuffered] = React.useState(0);
// handler
const handleBufferProgress: React.ReactEventHandler<HTMLAudioElement> = (e) => {
const audio = e.currentTarget;
const dur = audio.duration;
if (dur > 0) {
for (let i = 0; i < audio.buffered.length; i++) {
if (
audio.buffered.start(audio.buffered.length - 1 - i) < audio.currentTime
) {
const bufferedLength = audio.buffered.end(
audio.buffered.length - 1 - i,
);
setBuffered(bufferedLength);
break;
}
}
}
};
// markup
<audio
onTimeUpdate={(e) => {
setCurrrentProgress(e.currentTarget.currentTime);
handleBufferProgress(e);
}}
onProgress={handleBufferProgress}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>;
As per MDN the docs ,
The
buffered
attribute will tell us which parts of the media has been downloaded. It returns aTimeRanges
object, which will tell us which chunks of media have been downloaded.
Moreover, also from MDN docs, a TimeRanges
object consists of the following properties:
- length: The number of time ranges in the object.
- start(index): The start time, in seconds, of a time range.
- end(index): The end time, in seconds, of a time range.
Having the values of currentProgress
and buffered
states, we pass them to the AudioProgressBar component as follows:
<AudioProgressBar
duration={duration}
currentProgress={currrentProgress}
buffered={buffered}
onChange={(e) => {
if (!audioRef.current) return;
audioRef.current.currentTime = e.currentTarget.valueAsNumber;
setCurrrentProgress(e.currentTarget.valueAsNumber);
}}
/>
Previous/Next functions
Let us now add the ability to navigate through songs by providing our users a previous and a next button.
<div className="flex items-center gap-3 justify-self-center">
<IconButton
onClick={onPrev}
disabled={songIndex === 0}
aria-label="go to previous"
intent="secondary"
>
<MdSkipPrevious size={24} />
</IconButton>
<IconButton
disabled={!isReady}
onClick={togglePlayPause}
aria-label={isPlaying ? "Pause" : "Play"}
size="lg"
>
{!isReady && currentSong ? (
<CgSpinner size={24} className="animate-spin" />
) : isPlaying ? (
<MdPause size={30} />
) : (
<MdPlayArrow size={30} />
)}
</IconButton>
<IconButton
onClick={onNext}
disabled={songIndex === songCount - 1}
aria-label="go to next"
intent="secondary"
>
<MdSkipNext size={24} />
</IconButton>
</div>
A few points here:
- The
onPrev
andonNext
functions come from the props. - The previous button is disabled when the current track is the first one.
- The next button is disabled when the current track is the last track on the list.
In order to make sure that everytime we click the next or the previous button the current song is automatically played, we add the following effect. This is also to make sure that when the selected song is changed from the track list, the audio will play once it's ready.
React.useEffect(() => {
audioRef.current?.pause();
const timeout = setTimeout(() => {
audioRef.current?.play();
}, 500);
return () => {
clearTimeout(timeout);
};
}, [songIndex]);
Displaying the Elapsed time over Duration
For this we aim to show the user the current audio progress as it plays. This is achieved by creating a helper function that helps us extract the minutes and seconds components of a given duration.
function formatDurationDisplay(duration: number) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration - min * 60);
const formatted = [min, sec].map((n) => (n < 10 ? "0" + n : n)).join(":"); // format - mm:ss
return formatted;
}
We then add these lines to our AudioPlayer component:
const durationDisplay = formatDurationDisplay(duration);
const elapsedDisplay = formatDurationDisplay(currrentProgress);
Then before the buttons, we add the following markup:
<span className="text-xs">
{elapsedDisplay} / {durationDisplay}
</span>
Adding a song list UI
The song list UI is just a simple unordered list that renders audio tracks. Check its implementation below 👇:
import AudioPlayer from "./AudioPlayer";
import TrackItem from "./TrackItem";
import { songs } from "./songs";
function App() {
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
const currentSong = songs[currentSongIndex];
return (
<div className="not-prose border border-slate-800 rounded-lg my-10">
<div className="container mx-auto p-6 flex-1">
<h1 className="text-xl md:text-4xl font-bold mb-8">My Audio Player</h1>
<ul>
{songs.map((song, index) => (
<TrackItem
key={index}
selected={index === currentSongIndex}
title={song.title}
trackNumberLabel={song.trackNumber}
onClick={() => setCurrentSongIndex(index)}
/>
))}
</ul>
</div>
<AudioPlayer
key={currentSongIndex}
currentSong={currentSong}
songCount={songs.length}
songIndex={currentSongIndex}
onNext={() => setCurrentSongIndex((i) => i + 1)}
onPrev={() => setCurrentSongIndex((i) => i - 1)}
/>
</div>
);
}
The TrackItem
component looks like this:
import { MdPlayArrow, MdPause } from "react-icons/md";
import cn from "classnames";
interface TrackItemProps {
title: string;
trackNumberLabel: string;
selected: boolean;
onClick: () => void;
}
function TrackItem({
title,
trackNumberLabel,
selected,
onClick,
}: TrackItemProps) {
return (
<li
onClick={onClick}
className={cn(
"flex items-center py-3 px-3 w-full space-evenly rounded cursor-pointer mb-1",
{ "bg-cyan-600 text-white": selected },
{ "hover:bg-cyan-600 hover:text-white": !selected },
)}
>
<span className="text-sm inline-block">{trackNumberLabel}</span>
<h2 className="flex-1 text-base text-center">{title}</h2>
<span>
{selected ? <MdPause size={20} /> : <MdPlayArrow size={20} />}
</span>
</li>
);
}
export default TrackItem;
Now, a few points here:
- The initial songIndex is -1 to make sure that no song is selected on first load.
- When each track is clicked, the currentSong changes.
Bonus Section
1. Handle the event when a track has ended
<audio
ref={audioRef}
preload="metadata"
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onPlaying={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={onNext}
onCanPlay={(e) => {
e.currentTarget.volume = volume;
setIsReady(true);
}}
onTimeUpdate={(e) => {
setCurrrentProgress(e.currentTarget.currentTime);
handleBufferProgress(e);
}}
onProgress={handleBufferProgress}
onVolumeChange={(e) => setVolume(e.currentTarget.volume)}
>
<source type="audio/mpeg" src={currentSong.src} />
</audio>
2. <IconButton />
implementation
Here's how I implemented the IconButton component used in this post:
import React from "react";
import cn from "classnames";
type Intent = "primary" | "secondary";
type Size = "sm" | "md" | "lg";
interface IconButtonProps extends React.ComponentPropsWithoutRef<"button"> {
intent?: Intent; // can add more
size?: Size;
}
const colorMap: Record<Intent, string> = {
primary: "bg-cyan-600 text-white",
secondary: "bg-slate-800 text-slate-400",
};
const sizeMap: Record<Size, string> = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-12 w-12",
};
export default function IconButton({
intent = "primary",
size = "md",
className,
...props
}: IconButtonProps) {
const colorClass = colorMap[intent];
const sizeClass = sizeMap[size];
const classes = cn(
"rounded-full flex items-center justify-center ring-offset-slate-900 focus:outline-none focus:ring-2 focus:ring-cyan-600 focus:ring-offset-2 disabled:opacity-60",
colorClass,
sizeClass,
className,
);
return <button className={classes} {...props} />;
}
3. Stackblitz Playground
You can play around this simple project by forking the ff. playground in Stackblitz.
Conclusion
By leveraging the power of ReactJs and the native features of the audio HTML element, we're able to create a functional, dynamic, and fun audio player which we can use in many applications.
Happy coding!
-jep