Let's upgrade our little audio player in React
Thomas Pikauli
Posted on January 19, 2019
This is a bit of a part two. So if you missed part one, have a gander!
Let's make a little audio player in React
Thomas Pikauli ・ Jan 12 '19
In this session we're going to add a skip and back button to our little player. Also we're going to make a visual time indicator and a way to jump to a position in the track. This will require a bit of styling. I tried to keep it to a minimum so we can focus on the Javascript, but yes, we'll do some CSS.
I chose to just use a regular stylesheet and no specific library for the styling. Personally, I love CSS-in-JS solutions – they just feel right – but they are heavily contested. Since we'll just use barely enough styling to get by, let's not get into those discussions and just use whatever Create React App gave us.
In part one we did most of the wiring up. In this sequel we'll do a bit more logic. Let's start with the skip and back buttons. First we'll add the buttons to our JSX and clean up a bit of the logic.
{this.state.player !== "stopped" && (
<div className="buttons">
<button onClick={() => this.handleSkip("previous")}>Previous</button>
{this.state.player === "paused" && (
<button onClick={() => this.setState({ player: "playing" })}>
Play
</button>
)}
{this.state.player === "playing" && (
<button onClick={() => this.setState({ player: "paused" })}>
Pause
</button>
)}
<button onClick={() => this.setState({ player: "stopped" })}>Stop</button>
<button onClick={() => this.handleSkip("next")}>Skip</button>
</div>
);
}
I've also added an onClick
handler to the new buttons. You can see it calls the handleSkip
function and expects an argument. In this case the direction: 'forward' or 'back'. Next we'll add the handleSkip
function itself. Before we write it though, I've moved the array with our tracks to a constant at the top of our file. This is just static data that doesn't change.
const TRACKS = [
{ id: 1, title: "Campfire Story" },
{ id: 2, title: "Booting Up" }
];
For the function itself we have to figure out a few things:
- the index of the track that's currently playing
- the next or previous track based on the argument we received
- what we're going to do when there is no previous or next track!
Let's start with the easiest thing. We have our array with all the tracks that could possibly be playing at this moment, and we also have a piece of state which should contain the title of the track that is currently playing. That's enough to make use of the findIndex
method.
When using this method you get a number in return. Either it will be the index of the song we need, or it will be -1 aka "we didn't find what you we're looking for, sorry". Before we 'calculate' the next or previous track, we should really check how many tracks there are.
We can do that by grabbing the length of our TRACKS
array and subtracting 1. Why subtract 1? Because the length number is the exact amount of items in the array, while the index actually starts at zero. So 2 is 1, and 1 is 0. Hope that makes sense.
We've got two values now: the currentTrackIndex
and the tracksAmount
. That's enough to write the following logic.
handleSkip = direction => {
const currentTrackIndex = TRACKS.findIndex(
track => track.title === this.state.selectedTrack
);
const tracksAmount = TRACKS.length - 1;
if (direction === "previous") {
const previousTrack =
currentTrackIndex === 0 ? tracksAmount : currentTrackIndex - 1;
const trackToPlay = TRACKS[previousTrack];
this.setState({ selectedTrack: trackToPlay.title });
} else if (direction === "next") {
const nextTrack =
currentTrackIndex === tracksAmount ? 0 : currentTrackIndex + 1;
const trackToPlay = TRACKS[nextTrack];
this.setState({ selectedTrack: trackToPlay.title, duration: null });
}
};
If you've read or tried the code, you might have deduced how we handle the third thing we had to figure out: what we're going to do when there is no previous or next track. We're actually just going to play the first track when there is no next track, and play the last track when there is no previous track. That was a personal choice by me. We could've disabled the skip buttons too. Or hide them. But I didn't feel like that.
Anyway. With our buttons working, we can now focus on creating a time indicator. We're going to set up a new component for this, which we'll call TimeBar
.
<TimeBar
setTime={this.setTime}
currentTime={this.state.currentTime}
duration={this.state.duration}
/>
As you might see it takes three props: a function to set the time of the track, the current time of the track and the full duration of the track. The current time and duration we already set up in the previous article, so we only have to make a function for setting the time of a track to a specific point. This is actually not too difficult.
setTime = time => {
this.player.currentTime = time;
};
The function receives a value I called time
. Then we access our player using the ref
we placed in our earlier session. The player has value called currentTime
we can mutate to the value we received into our function. That is all really.
Now for the component itself. Let's think a bit about what it should do. It needs to visually show how long the track is. It then needs to visually show where the track is. And finally you should be able to click anywhere on the bar to jump to that position in the song.
We're going to do this all in pure React, we're going to use my favorite Javascript method .map
, and a lovely oldskool while
loop. Let me show you the first bit, so we can talk about it.
function TimeBar({ currentTime, duration, setTime }) {
const sBits = [];
let count = 0;
while (count < duration) {
sBits.push(count);
count++;
}
return <div className="timebar" />;
}
In React you can just use a function as a component. The props you've passed it enter the scene as arguments you can destructure. It's quite neat. Now for the first bit of logic. Why is it there, and what does it do?
Well, let's put on our thinking hats again for a minute and try to visualize in our mind's eye how we can display the duration (which is just a number) as a graphical bar consisting of bits of time. We can only do that if we turn the number into bits first. Luckily someone came up with the concept of seconds and even more lucky: the duration is also in seconds.
That means we can create an empty array and then start throwing our bits into it. We do that by starting a loop that will keep looping as long as our count is smaller than the duration. With each loop we add a number to our count, so we can be sure that at some point the count will become higher than the duration, effectively stopping our loop.
If all went correctly the loop should execute code for exactly the amount of full seconds there are in the duration. So we can fill our array with one bit on each turnover.
That's good. But how can we display those bits to become a bar. And let's just get ambitious. How can we make it so that we see the actual time of the track visualized on that bar?
const seconds = sBits.map(second => {
return (
<div
key={second}
style={{
float: "left",
cursor: "pointer",
height: "30px",
width: `${300 / Math.round(duration)}px`,
background:
currentTime && Math.round(currentTime) === second ? "white" : "black",
transition: "all 0.5s ease-in-out"
}}
/>
);
});
There's a lot going on here. Let's digest it. The .map
method allows us to take our array with seconds, and for each of those seconds return something of our liking. In our case, we're going to return a div
, so that when placed next to each other, they form a nice horizontal bar.
The div
has a key
, which let's React keep track of which div
is which. For that reason it has to be unique. All our seconds are different, so it's fine to use that for a key
. Next we have our styling. The float
makes sure the seconds are rendered side by side, but more interesting are the width
and background
.
Now you can't see this here, but in our CSS file I have decided that our timebar will be 300 pixels wide. This was an arbitrary choice, but one that affects the width of our seconds; we want all our seconds to be evenly spread across the bar. We can make that happen by calculating the width via a simple division. The Math.round
is there to round the duration up to a whole number of seconds.
So we have a bar with divs
evenly spread across its container each representing a second within the duration of the track. We also happen to have passed the current time of the track into our component. This piece of data is in seconds as well. Which is perfect. That means if we round it to whole seconds, we can match it to the seconds in our timebar.
Using a ternary operator we can compare our two values, and decide whether to render our div
as white or black. With black being no match, and white being a match.
The result of our .map
is saved to a variable I have called seconds. We only have to place that variable in the return
of our component to make it render on the screen. As you might notice I have also added the formattedCurrentTime
and formattedDuration
to our return
. These are the values we calculated in the first article.
return (
<div className="timebar">
<div className="bar">{seconds}</div>
{currentTime ? (
<div className="time">
{formattedCurrentTime} / {formattedDuration}
</div>
) : (
""
)}
</div>
);
There a bunch of classNames
in this snippet. And there are more in the full code. They refer to CSS. I won't lay them out here, instead you can check out the styles.css
in this CodeSandBox . They help put everything in the correct place.
Depending on your creativity, determination and skill you can make something really nice out of it. Or at least something that's a bit more spectacular then what I have created. My excuse is of course that I wanted to focus on the Javascript 😅.
Anyway, that concludes the upgrade of our little player. Now let's play some music and enjoy our day.
Final addendum! What if our track is nicely playing, but it ends? What happens then? Surely it should play the next track right, if there is still one? I think so too. So let's quickly set it up, before I really let you go.
if (
this.state.duration &&
!isNaN(this.state.duration) &&
this.state.duration === this.state.currentTime
) {
const currentTrackIndex = TRACKS.findIndex(
track => track.title === this.state.selectedTrack
);
const tracksAmount = TRACKS.length - 1;
if (currentTrackIndex === tracksAmount) {
this.setState({
selectedTrack: null,
player: "stopped",
currentTime: null,
duration: null
});
} else {
this.handleSkip("next");
}
}
The above code is part of the componentDidUpdate
lifecycle. It check to see if there is currently a duration and if that duration is equal to the current time of the track.
If that is the case it would mean the track has ended. We then do a quick check to see if this was the last track of our playlist, or if that there is still another one to be played. If this was really it, then we set all our state back to default. If there is still a next track, then we just simply call the handleSkip
function we created earlier.
That's really it for now!
Posted on January 19, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.