A while ago I built my portfolio and I wrote a custom audio player to showcase my tracks. Since then quite a few people have been interested in its technical implementation. I've always replied I might write a full-length tutorial for it, yet the strict deadline for it was never set.
In April 2022, I saw a Writeathon being announced by Hashnode and one of the entrance categories being Web applications. I knew this was a perfect opportunity to enter and finally get the job done. I'm glad I stumbled upon it since it really motivated me.
This article would be beneficial not only for the practical end result you will get but also for educational purposes for people that are looking to switch careers to app development or explore the React ecosystem, due to the following couple of reasons:
I will show the full app creation cycle from feature planning, wireframing, and designing, to creating components, implementing logic, adding responsiveness, and deploying the app.
It will teach you how to think in React, which is quite a big transition when switching from Vanilla JavaScript, for example. You will learn how to set up and structure React app, as well as some of the best practices and thinking patterns of the library.
Here is the deployed preview and use of the music player on my portfolio to get you an insight into what we will be building in this tutorial:
The source code of the audio player is open-source. I have also made an NPM package out of it so you can easily set it up in your existing projects, as well.
Planning the features
The most basic audio players usually come with a minimal set of features like a play/pause button, volume, or progress controls, which might be a good solution if you want to play a single track and do not have to match the player to the design of the site.
Though, if you care for some extra functionality and the experience of your end-users, chances are you will want some advanced solution.
In this tutorial, we will be focusing on a more complex case where you have multiple tracks to showcase, need to implement a way to quickly find or filter them, and want to control the behavior of the play order. The full list of features we will implement include:
- Play and Pause audio
- Next and Previous tracks
- Repeat the track
- Shuffle track order
- Progress slider
- Time left / Total time
- Volume slider
- Search track
- Filter tracks by genre
- Playlist items
Creating a wireframe
The audio player will use a straightforward user interface with different functionalities divided into separate components. This will make the audio player intuitive and improve the overall user experience when interacting with it.
The whole wireframe of the app will look like this:
We will use Template
components as the main containers for the children. If the children include other elements themselves, they will be wrapped in Box
components.
The whole app will be wrapped into the PageTemplate
wrapper, which will include the children components: TagsTemplate
, Search
, PlayerTemplate
, and PlaylistTemplate
.
TagsTemplate
will further include the children TagItem
, PlayerTemplate
will include TitleAndTimeBox
, Progress
and ButtonsAndVolumeBox
, while the PlaylistTemplate
will include PlaylistItem
component.
Even further the TitleAndTimeBox
component will include Title
and Time
components, while ButtonsAndVolumeBox
will include ButtonsBox
and Volume
components.
Finally, ButtonsBox
will include all the Button
components for user controls.
Designing the app
The design of the audio player will be based on maximum accessibility so that all of the information is easy to read and all action buttons are easy to distinguish from the background panels of the player.
To achieve that the following color scheme will be used:
The tags will have a purple background color to give them an accent to the main color scheme used in the rest of the audio player. This will give a great notice to the user about the included genres of the tracks. To further improve the user experience they will change the background color to green on the hover event.
The search will have a dark background, with the grey placeholder text displayed on it. The placeholder text color will be less accented from the rest of the text purposely, in order to notify the user that the input value is expected. Once typed in the input text will be displayed in white.
The player itself will have a dark background color and all the included text for the track, title and time will be white to give maximum contrast. Further, all the icons in the player will be in white as well, so they stand out from the dark background.
For the progress bar and volume slider, the used progress will be in white, while the left progress will be in a darker shade. The slider knobs will use the same background color as the tags, so the user is notified that they can interact with them.
Finally, all the playlist items will have a dark background as well. To give the accent to the currently played track, it will have a white color while the rest of the inactive tracks in the playlist will have the same color as the search placeholder.
Fonts
Three different font families will be used for the audio player. Down below I will describe which elements will use what font families and give a preview with some sample text.
- The tag text and the current/total time components will use Varela round font.
- The track title, search placeholder value, and the active playlist items will use Quicksand font.
- The inactive playlist items will use the Poppins font.
If you want to use any other font families, feel free to choose some alternatives in Google fonts. There are tons of fonts to choose from, just make sure to replace them in the style sheets where they will be used in the project.
Setting up React app
To get started with a boilerplate, we will use Create React App, which is an officially supported CLI tool, that lets you create a new ReactJS project within a minute or less.
Open your terminal and run the following command: npx create-react-app@latest audio-player
. Wait a couple of minutes and the terminal wizard should finish installing the necessary dependencies for the project.
Then change your current working directory into the newly created project folder by running cd audio-player
and run npm start
to start the development server.
Now open your browser, navigate to http://localhost:3000 and you should be presented with the ReactJS app template, which looks like this:
Switch back to the project and see the files folder tree. Navigate to the src
directory and remove all the files from it currently, since we are be creating everything from scratch.
Set the basis of the app
We will first create the root file of the app, that will render the whole application.
To do that, navigate to the src
folder and create a new file index.js
. Make sure to include the following code:
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { Player } from "./App";
const tracks = [
{
url: "https://audioplayer.madza.dev/Madza-Chords_of_Life.mp3",
title: "Madza - Chords of Life",
tags: ["house"],
},
{
url: "https://audioplayer.madza.dev/Madza-Late_Night_Drive.mp3",
title: "Madza - Late Night Drive",
tags: ["dnb"],
},
{
url: "https://audioplayer.madza.dev/Madza-Persistence.mp3",
title: "Madza - Persistence",
tags: ["dubstep"],
},
];
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Player trackList={tracks} />
</React.StrictMode>
);
First, we imported React
and ReactDOM
so we are able to create a render function in the file. We also imported the stylesheet file, which we will create after we are done creating this file, as well as already included the Player
component where our app logic will live.
For each track we will need its source, title, and tags, so we already created an array of objects consisting of three sample tracks, that will be passed in the Player
component as a prop.
The audio source is from my deployed example project, so you don't have to search for audio tracks online. Alternatively, you can upload some local files into the project and link to them.
Next, while in src
folder, create a new file index.css
and include these style rules:
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background-color: #151616;
}
:root {
--tagsBackground: #9440f3;
--tagsText: #ffffff;
--tagsBackgroundHoverActive: #2cc0a0;
--tagsTextHoverActive: #ffffff;
--searchBackground: #18191f;
--searchText: #ffffff;
--searchPlaceHolder: #575a77;
--playerBackground: #18191f;
--titleColor: #ffffff;
--timeColor: #ffffff;
--progressSlider: #9440f3;
--progressUsed: #ffffff;
--progressLeft: #151616;
--volumeSlider: #9440f3;
--volumeUsed: #ffffff;
--volumeLeft: #151616;
--playlistBackground: #18191f;
--playlistText: #575a77;
--playlistBackgroundHoverActive: #18191f;
--playlistTextHoverActive: #ffffff;
}
First, we imported Varela Round, Quicksand, and Poppins fonts from Google fonts.
Then we reset the rules for all elements on the app to make sure all the elements look the same on every browser. We removed the padding and margin as well as configured the box-sizing to include padding and margin in the width and height.
Finally, we set the body background color and created a global color scheme that we will use throughout the entire app. Thanks to the :root
selector, each of the colors can later be accessed via var(--property)
.
Downloading icons
In order to deliver a great user experience for audio controls, we will use .PNG icons for play, pause, loop, shuffle playlist order and switch to previous and next tracks.
In order to keep track of the states for loop and shuffle buttons, the white icon will be used for inactive, while the grey one will be used for the active state.
I have compiled a downloadable pack with all the icons, which you can download here. Make sure extract the folder and include it in the src
directory.
Alternatively, you can download your own icons on websites like flaticon.com or icons8.com. Just make sure you rename them the same as in the download pack above.
Creating the Components
In our audio player, we will use 20 components. For most of the components, we will create separate JS and CSS module files. You can create them manually, though I would recommend running the following command that will create everything you need in seconds:
mkdir components && cd components && touch PageTemplate.js TagsTemplate.js TagsTemplate.module.css TagItem.js TagItem.module.css Search.js Search.module.css PlayerTemplate.js PlayerTemplate.module.css TitleAndTimeBox.js TitleAndTimeBox.module.css Title.js Title.module.css Time.js Time.module.css Progress.js Progress.module.css ButtonsAndVolumeBox.js ButtonsAndVolumeBox.module.css ButtonsBox.js ButtonsBox.module.css Loop.js Loop.module.css Previous.js Previous.module.css Play.js Play.module.css Pause.js Pause.module.css Next.js Next.module.css Shuffle.js Shuffle.module.css Volume.js Volume.module.css PlaylistTemplate.js PlaylistTemplate.module.css PlaylistItem.js PlaylistItem.module.css
.
Once all the components are created, let's populate each of them with code and style rules.
Open PageTemplate.js
and include the following code:
export const PageTemplate = ({ children }) => {
return <div>{children}</div>;
};
This is a main wrapper component for the app, that will include all the children components we will create in the upcoming steps.
Open TagsTemplate.js
and include the following code:
import styles from "./TagsTemplate.module.css";
export const TagsTemplate = ({ children }) => {
return <div className={styles.wrapper}>{children}</div>;
};
This will be a wrapper component for all the tags we will use and will make sure they follow a proper layout.
Open the TagsTemplate.module.css
and include the following style rules:
.wrapper {
width: 100%;
margin: 20px auto;
height: auto;
color: var(--primaryText);
display: inline-block;
text-align: center;
}
We first set the width to take all the available width in the wrapper, added some margin to the top and bottom, set the color to be used in the tag's text, align it to the center, and made sure the tags will be shown as inline elements horizontally.
Open TagItem.js
and include the following code:
import styles from "./TagItem.module.css";
export const TagItem = ({ status, onClick, tag }) => {
return (
<div
className={`${styles.tag} ${status === "active" ? styles.active : ""}`}
onClick={onClick}
>
{tag}
</div>
);
};
These will be the tag components themselves. Each will receive the status
prop to control which of the tag is active via custom styling rules, the onClick
prop that will describe what happens when the tags are being clicked on, and the tag
prop to give a title for each tag.
Open the TagItem.module.css
and include the following style rules:
.tag {
background-color: var(--tagsBackground);
color: var(--tagsText);
height: 40px;
min-width: 100px;
display: inline-grid;
place-items: center;
margin: 5px 5px;
transition: transform 0.2s;
padding: 0 10px;
font-family: 'Varela Round', sans-serif;
border-radius: 10px;
font-size: 18px;
}
.active {
background-color: var(--tagsBackgroundHoverActive);
color: var(--tagsTextHoverActive);
}
.tag:hover {
background-color: var(--tagsBackgroundHoverActive);
color: var(--tagsTextHoverActive);
cursor: pointer;
transform: scale(1.1);
}
We set the background and text color, defined the height and width, centered the content, added some margin and padding, set the font size, and added some rounded corners for the playlist items.
For the active tags, we set different backgrounds and text colors. For the hovered tags we also set a different color for background and text, as well as added some size scaling, and changed the cursor to the pointer.
Open Search.js
and include the following code:
import styles from "./Search.module.css";
export const Search = ({ onChange, value, placeholder }) => {
return (
<input
type="text"
className={styles.search}
onChange={onChange}
value={value}
placeholder={placeholder}
/>
);
};
The search component will have an onChange
prop that will describe the behavior when the input value is being changed, the value
prop to track the value being entered, and the placeholder
prop to display the placeholder text when there is no input in the search bar.
Open the Search.module.css
and include the following style rules:
.search {
font-family: 'Quicksand', sans-serif;
height: 40px;
border: none;
font-size: 18px;
width: 100%;
margin: 0 auto 10px auto;
background-color: var(--searchBackground);
color: var(--searchText);
padding-left: 20px;
border-radius: 10px;
}
.search::placeholder {
color: var(--searchPlaceHolder);
}
We set the font family, font size and color for the text, and specific height of the bar and made sure it uses all the available width of the parent. We also added some margin to the bottom and the padding to the left, as well as removed the default border and set rounded corners.
For the placeholder value, we set the text color.
Open PlayerTemplate.js
and include the following code:
import styles from "./PlayerTemplate.module.css";
export const PlayerTemplate = ({ children }) => {
return <div className={styles.wrapper}>{children}</div>;
};
This will be the main wrapper for the player component that will include all of the children and sub-children components.
Open the PlayerTemplate.module.css
and include the following style rules:
.wrapper {
border-radius: 10px;
padding: 0 40px;
background-color: var(--playerBackground);
overflow: auto;
font-family: 'Quicksand', sans-serif;
}
In the style rules, we made sure the wrapper has some left and right padding, dark background color, specific font family, nice rounded corners, and auto overflow behavior.
Open TitleAndTimeBox.js
and include the following code:
import styles from "./TitleAndTimeBox.module.css";
export const TitleAndTimeBox = ({ children }) => {
return <div className={styles.wrapper}>{children}</div>;
};
This is the first children component for the player wrapper and will include the title and time components.
Open the TitleAndTimeBox.module.css
and include the following style rules:
.wrapper {
display: grid;
grid-template-columns: auto 200px;
margin: 30px 0 20px 0;
}
We made sure the wrapper uses the grid layout, splitting the available with into two columns, where the left column is calculated from the available space, subtracting the width from the right column, which is set to be 200px
. We also made sure there is some top and bottom margin for the wrapper.
Open Title.js
and include the following code:
import styles from "./Title.module.css";
export const Title = ({ title }) => {
return <h1 className={styles.title}>{title}</h1>;
};
The title component will include the title
prop, which will display the name of the track.
Open the Title.module.css
and include the following style rules:
.title {
color: var(--titleColor);
font-size: 28px;
}
We set the color for the title and set the specific font size for it.
Open Time.js
and include the following code:
import styles from "./Time.module.css";
export const Time = ({ time }) => {
return <h1 className={styles.time}>{time}</h1>;
};
The time component will receive the time
prop that will display the played and the total time of the track.
Open the Time.module.css
and include the following style rules:
.time {
font-family: 'Varela Round', sans-serif;
color: var(--timeColor);
text-align: right;
font-size: 30px;
}
We set the font family, size, and color for the text, and aligned it to the right.
Open Progress.js
and include the following code:
import styles from "./Progress.module.css";
export const Progress = ({ value, onChange, onMouseUp, onTouchEnd }) => {
return (
<div className={styles.container}>
<input
type="range"
min="1"
max="100"
step="1"
value={value}
className={styles.slider}
id="myRange"
onChange={onChange}
onMouseUp={onMouseUp}
onTouchEnd={onTouchEnd}
style={{
background: `linear-gradient(90deg, var(--progressUsed) ${Math.floor(
value
)}%, var(--progressLeft) ${Math.floor(value)}%)`,
}}
/>
</div>
);
};
The progress component will receive the value
prop to get the current value of the range, the onChange
prop to control the behavior when the slider knob is dragged, the onMouseUp
prop to pass the event when the user releases a mouse button and the onTouchEnd
prop for events when one or more touchpoints are removed from the touch surface for touchscreen devices.
We also set the minimum value of the range to be 1
and the maximum to be 100
with the increase step of 1
. To make the used progress and left progress in different colors we set custom styling and included a linear gradient background with a 90
degree angle.
Open the Progress.module.css
and include the following style rules:
.container {
display: grid;
place-items: center;
margin-bottom: 20px;
}
.slider {
-webkit-appearance: none;
width: 100%;
height: 4px;
border-radius: 5px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 25px;
height: 25px;
border-radius: 50%;
background: var(--progressSlider);
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--progressSlider);
cursor: pointer;
}
We wrapped the progress bar component and centered it in the grid layout, also setting some bottom margin to separate the progress bar from the below components.
We set the slider bar itself to take all of the available width of the parent, set its height, removed the default styling, and added some border radius to both ends of the bar.
For the slider knob itself, we removed its default styling, set its background color to be the same as the tags, added a fixed width and height, made the knob a circle, and set the cursor to be a pointer when interacting with it.
Open ButtonsAndVolumeBox.js
and include the following code:
import styles from "./ButtonsAndVolumeBox.module.css";
export const ButtonsAndVolumeBox = ({ children }) => {
return <div className={styles.wrapper}>{children}</div>;
};
This will be a wrapper component that will include the button box and volume bar.
Open the ButtonsAndVolumeBox.module.css
and include the following style rules:
.wrapper {
display: grid;
grid-template-columns: auto 30%;
margin-bottom: 30px;
}
We made sure the wrapper uses the grid layout and separated it into two columns, where the one on the right is 30
percent while the other one on the left takes the rest of the available space. We also set some margin to the bottom to separate it from the components below.
Open ButtonsBox.js
and include the following code:
import styles from "./ButtonsBox.module.css";
export const ButtonsBox = ({ children }) => {
return <div className={styles.wrapper}>{children}</div>;
};
This component will include all the buttons for audio controls as children.
Open the ButtonsBox.module.css
and include the following style rules:
.wrapper {
display: grid;
grid-template-columns: repeat(5, auto);
place-items: center;
}
We made sure we use the grid layout and separated the available space into five columns that are equal in their width. We also centered the items in the columns.
Open Loop.js
and include the following code:
import styles from "./Loop.module.css";
export const Loop = ({ src, onClick }) => {
return <img className={styles.loop} src={src} onClick={onClick} />;
};
The loop component will be used to loop the current track after it's finished playing. It will receive the src
prop that will provide the source for the loop icon and the onClick
prop that will receive the action function when it gets clicked.
Open the Loop.module.css
and include the following style rules:
.loop {
width: 26px;
height: 26px;
transition: transform 0.2s;
}
.loop:hover {
cursor: pointer;
transform: scale(1.2);
}
We set the specific width and height for the icon and added a nice transition effect so that when the user hovers over the icon it gets zoomed in a bit. Also when the user hovers over the icon the cursor will change to a pointer.
Open Previous.js
and include the following code:
import styles from "./Previous.module.css";
export const Previous = ({ src, onClick }) => {
return <img className={styles.previous} src={src} onClick={onClick} />;
};
This component will allow us to switch to the previous track. It will receive the src
prop for the source of the icon and the onClick
prop for the action when it gets clicked.
Open the Previous.module.css
and include the following style rules:
.previous {
width: 50px;
height: 50px;
transition: transform 0.2s;
}
.previous:hover {
cursor: pointer;
transform: scale(1.2);
}
We set a bigger width and height size than the loop component. We also added the size transition on hover as well as the pointer for the cursor.
Open Play.js
and include the following code:
import styles from "./Play.module.css";
export const Play = ({ src, onClick }) => {
return <img className={styles.play} src={src} onClick={onClick} />;
};
The play component will allow us to play the tracks. It will receive the src
prop for the source of the icon as well as the onClick
prop for the action when it gets clicked.
Open the Play.module.css
and include the following style rules:
.play {
width: 60px;
height: 60px;
transition: transform 0.2s;
}
.play:hover {
cursor: pointer;
transform: scale(1.2);
}
We set an even bigger size for the width and height of the icon to accent it more. Same as before, we added the size increase and cursor change on hover.
Open Pause.js
and include the following code:
import styles from "./Pause.module.css";
export const Pause = ({ src, onClick }) => {
return <img className={styles.pause} src={src} onClick={onClick} />;
};
The pause component will let us stop the audio. It will receive the src
prop for the icon source and the onClick
prop for the action when it gets clicked.
Open the Pause.module.css
and include the following style rules:
.pause {
width: 60px;
height: 60px;
transition: transform 0.2s;
}
.pause:hover {
cursor: pointer;
transform: scale(1.2);
}
We set the same width and height as for the play component, as well as included the size increase and pointer for the cursor on hover.
Open Next.js
and include the following code:
import styles from "./Next.module.css";
export const Next = ({ src, onClick }) => {
return <img className={styles.next} src={src} onClick={onClick} />;
};
This component will allow us to switch to the next tracks. It will receive the src
prop for the icon source and the onClick
prop for the action when it gets clicked.
Open the Next.module.css
and include the following style rules:
.next {
width: 50px;
height: 50px;
transition: transform 0.2s;
}
.next:hover {
cursor: pointer;
transform: scale(1.2);
}
We set the same width and height as for the component that lets us switch to previous tracks. Also, we added the size increase of the icon and cursor change on the hover.
Open Shuffle.js
and include the following code:
import styles from "./Shuffle.module.css";
export const Shuffle = ({ src, onClick }) => {
return <img className={styles.shuffle} src={src} onClick={onClick} />;
};
The final button component will be the shuffle which will allow us to mix the order of the playlist tracks. The src
prop will be for the icon source and the onClick
prop will receive an action when it gets clicked.
Open the Shuffle.module.css
and include the following style rules:
.shuffle {
width: 26px;
height: 26px;
transition: transform 0.2s;
}
.shuffle:hover {
cursor: pointer;
transform: scale(1.2);
}
We set the width and the height for the icon to be the same as for the loop component. Finally, we added the size increase effect and changed the cursor to the pointer on hover.
Open Volume.js
and include the following code:
import styles from "./Volume.module.css";
export const Volume = ({ onChange, value }) => {
return (
<div className={styles.wrapper}>
<input
type="range"
min="1"
max="100"
defaultValue="80"
className={styles.slider}
id="myRange"
onChange={onChange}
style={{
background: `linear-gradient(90deg, var(--volumeUsed) ${
value * 100
}%, var(--volumeLeft) ${value * 100}%)`,
}}
/>
</div>
);
};
The volume component will allow us to change the volume of the audio being played. It will receive the onChange
prop that will allow us to pass the action when the slider is being changed, as well as the value
prop that will let us track its current value of it.
It will use the input range with the minimal value of 1
and the maximum of 100
with the increase and decrease of the step of 1
. Similarly as for the progress component earlier, in order to display the used and left part of the range in a different color, we used the linear gradient.
Open the Volume.module.css
and include the following style rules:
.wrapper {
display: grid;
place-items: center;
min-height: 60px;
}
.slider {
-webkit-appearance: none;
width: 70%;
height: 3px;
border-radius: 5px;
background: var(--volumeSlider);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--volumeSlider);
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--volumeSlider);
cursor: pointer;
}
We wrapped the volume bar into the container with a grid layout and centered it. We also set the height for it to fit in the parent layout.
For the slider itself, we first removed the default styling, then set it to use the 70
percent of the available space and set the specific height. We also added a border-radius for rounded corners of the slider and set the background color.
For the slider knob, we removed the custom styling and set the same background as for the progress component. We also made it round, though made it smaller than the one in the progress component. Finally, we will use a pointer effect for the cursor on hover.
Open PlaylistTemplate.js
and include the following code:
import styles from "./PlaylistTemplate.module.css";
export const PlaylistTemplate = ({ children }) => {
return <div className={styles.wrapper}>{children}</div>;
};
This component will be the wrapper for all the playlist items.
Open the PlaylistTemplate.module.css
and include the following style rules:
.wrapper {
margin: 20px auto;
max-height: 425px;
min-height: 120px;
overflow-x: hidden;
padding-right: 10px;
font-family: "Quicksand", sans-serif;
}
.wrapper::-webkit-scrollbar {
width: 5px;
}
.wrapper::-webkit-scrollbar-track {
border-radius: 10px;
}
.wrapper::-webkit-scrollbar-thumb {
background: var(--primaryText);
border-radius: 10px;
}
We made sure we set some margin to the top and the bottom, set the height, set the overflow on the x-axis to be hidden added some padding to the left, and set the font family for the text of the included playlist items.
The user will be allowed to scroll if some of the playlist items are outside the height of the playlist wrapper. For that, we created a custom scrollbar. We set its width, border radius, and background color.
Open PlaylistItem.js
and include the following code:
import styles from "./PlaylistItem.module.css";
export const PlaylistItem = ({ status, data_key, src, title, onClick }) => {
return (
<p
className={`${styles.item} ${status === "active" ? styles.active : ""}`}
data-key={data_key}
src={src}
title={title}
onClick={onClick}
>
{title}
</p>
);
};
This is the actual playlist item that will receive the status
prop to control the active item, the data_key
prop so we can later identify it, the src
prop for the audio source, the title
prop to display the title of the audio and the onClick
prop to control the behavior on the click.
Open the PlaylistItem.module.css
and include the following style rules:
.item {
background-color: var(--playlistBackground);
color: var(--playlistText);
text-align: center;
margin: 5px 0;
padding: 3px 0;
border-radius: 5px;
font-size: 16px;
font-family: 'Poppins', sans-serif;
}
.active {
color: var(--playlistTextHoverActive);
font-family: 'Quicksand', sans-serif;
font-size: 18px;
}
.item:hover {
color: var(--playlistTextHoverActive);
cursor: pointer;
}
We set custom background and text color, aligned the text to be displayed in the center, set some margin and padding, set font size and family as well as added some rounded corners.
For the active items, we changed the text color, as well as font size and family. We also set different text colors for the items on hover and changed the cursor to a pointer.
Putting the logic together
Now navigate back to the src
folder and create the App.js
which will be the main file where our music player logic will live. Include the following code:
import { useState, useEffect, useRef } from "react";
import { PageTemplate } from "./components/PageTemplate";
import { TagsTemplate } from "./components/TagsTemplate";
import { TagItem } from "./components/TagItem";
import { Search } from "./components/Search";
import { PlayerTemplate } from "./components/PlayerTemplate";
import { TitleAndTimeBox } from "./components/TitleAndTimeBox";
import { Title } from "./components/Title";
import { Time } from "./components/Time";
import { Progress } from "./components/Progress";
import { ButtonsAndVolumeBox } from "./components/ButtonsAndVolumeBox";
import { ButtonsBox } from "./components/ButtonsBox";
import { Loop } from "./components/Loop";
import { Previous } from "./components/Previous";
import { Play } from "./components/Play";
import { Pause } from "./components/Pause";
import { Next } from "./components/Next";
import { Shuffle } from "./components/Shuffle";
import { Volume } from "./components/Volume";
import { PlaylistTemplate } from "./components/PlaylistTemplate";
import { PlaylistItem } from "./components/PlaylistItem";
import loopCurrentBtn from "./icons/loop_current.png";
import loopNoneBtn from "./icons/loop_none.png";
import previousBtn from "./icons/previous.png";
import playBtn from "./icons/play.png";
import pauseBtn from "./icons/pause.png";
import nextBtn from "./icons/next.png";
import shuffleAllBtn from "./icons/shuffle_all.png";
import shuffleNoneBtn from "./icons/shuffle_none.png";
const fmtMSS = (s) => new Date(1000 * s).toISOString().substr(15, 4);
export const Player = ({ trackList }) => {
const [audio, setAudio] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
const [title, setTitle] = useState("");
const [length, setLength] = useState(0);
const [time, setTime] = useState(0);
const [slider, setSlider] = useState(1);
const [drag, setDrag] = useState(0);
const [volume, setVolume] = useState(0.8);
const [shuffled, setShuffled] = useState(false);
const [looped, setLooped] = useState(false);
let playlist = [];
const [filter, setFilter] = useState([]);
let [curTrack, setCurTrack] = useState(0);
const [query, updateQuery] = useState("");
const tags = [];
trackList.forEach((track) => {
track.tags.forEach((tag) => {
if (!tags.includes(tag)) {
tags.push(tag);
}
});
});
useEffect(() => {
const audio = new Audio(trackList[curTrack].url);
const setAudioData = () => {
setLength(audio.duration);
setTime(audio.currentTime);
};
const setAudioTime = () => {
const curTime = audio.currentTime;
setTime(curTime);
setSlider(curTime ? ((curTime * 100) / audio.duration).toFixed(1) : 0);
};
const setAudioVolume = () => setVolume(audio.volume);
const setAudioEnd = () => setHasEnded(!hasEnded);
audio.addEventListener("loadeddata", setAudioData);
audio.addEventListener("timeupdate", setAudioTime);
audio.addEventListener("volumechange", setAudioVolume);
audio.addEventListener("ended", setAudioEnd);
setAudio(audio);
setTitle(trackList[curTrack].title);
return () => {
audio.pause();
};
}, []);
useEffect(() => {
if (audio != null) {
audio.src = trackList[curTrack].url;
setTitle(trackList[curTrack].title);
play();
}
}, [curTrack]);
useEffect(() => {
if (audio != null) {
if (shuffled) {
playlist = shufflePlaylist(playlist);
}
!looped ? next() : play();
}
}, [hasEnded]);
useEffect(() => {
if (audio != null) {
audio.volume = volume;
}
}, [volume]);
useEffect(() => {
if (audio != null) {
pause();
const val = Math.round((drag * audio.duration) / 100);
audio.currentTime = val;
}
}, [drag]);
useEffect(() => {
if (!playlist.includes(curTrack)) {
setCurTrack((curTrack = playlist[0]));
}
}, [filter]);
const loop = () => {
setLooped(!looped);
};
const previous = () => {
const index = playlist.indexOf(curTrack);
index !== 0
? setCurTrack((curTrack = playlist[index - 1]))
: setCurTrack((curTrack = playlist[playlist.length - 1]));
};
const play = () => {
setIsPlaying(true);
audio.play();
};
const pause = () => {
setIsPlaying(false);
audio.pause();
};
const next = () => {
const index = playlist.indexOf(curTrack);
index !== playlist.length - 1
? setCurTrack((curTrack = playlist[index + 1]))
: setCurTrack((curTrack = playlist[0]));
};
const shuffle = () => {
setShuffled(!shuffled);
};
const shufflePlaylist = (arr) => {
if (arr.length === 1) return arr;
const rand = Math.floor(Math.random() * arr.length);
return [arr[rand], ...shufflePlaylist(arr.filter((_, i) => i !== rand))];
};
const tagClickHandler = (e) => {
const tag = e.currentTarget.innerHTML;
if (!filter.includes(tag)) {
setFilter([...filter, tag]);
} else {
const filteredArray = filter.filter((item) => item !== tag);
setFilter([...filteredArray]);
}
};
const playlistItemClickHandler = (e) => {
const num = Number(e.currentTarget.getAttribute("data-key"));
const index = playlist.indexOf(num);
setCurTrack((curTrack = playlist[index]));
play();
};
return (
<PageTemplate>
<TagsTemplate>
{tags.map((tag, index) => {
return (
<TagItem
key={index}
status={
filter.length !== 0 && filter.includes(tag) ? "active" : ""
}
tag={tag}
onClick={tagClickHandler}
/>
);
})}
</TagsTemplate>
<Search
value={query}
onChange={(e) => updateQuery(e.target.value.toLowerCase())}
placeholder={`Search ${trackList.length} tracks...`}
/>
<PlayerTemplate>
<TitleAndTimeBox>
<Title title={title} />
<Time
time={`${!time ? "0:00" : fmtMSS(time)}/${
!length ? "0:00" : fmtMSS(length)
}`}
/>
</TitleAndTimeBox>
<Progress
value={slider}
onChange={(e) => {
setSlider(e.target.value);
setDrag(e.target.value);
}}
onMouseUp={play}
onTouchEnd={play}
/>
<ButtonsAndVolumeBox>
<ButtonsBox>
<Loop src={looped ? loopCurrentBtn : loopNoneBtn} onClick={loop} />
<Previous src={previousBtn} onClick={previous} />
{isPlaying ? (
<Pause src={pauseBtn} onClick={pause} />
) : (
<Play src={playBtn} onClick={play} />
)}
<Next src={nextBtn} onClick={next} />
<Shuffle
src={shuffled ? shuffleAllBtn : shuffleNoneBtn}
onClick={shuffle}
/>
</ButtonsBox>
<Volume
value={volume}
onChange={(e) => {
setVolume(e.target.value / 100);
}}
/>
</ButtonsAndVolumeBox>
</PlayerTemplate>
<PlaylistTemplate>
{trackList
.sort((a, b) => (a.title > b.title ? 1 : -1))
.map((el, index) => {
if (
filter.length === 0 ||
filter.some((filter) => el.tags.includes(filter))
) {
if (el.title.toLowerCase().includes(query.toLowerCase())) {
playlist.push(index);
return (
<PlaylistItem
status={curTrack === index ? "active" : ""}
key={index}
data_key={index}
title={el.title}
src={el.url}
onClick={playlistItemClickHandler}
/>
);
}
}
})}
</PlaylistTemplate>
</PageTemplate>
);
};
First, we imported useState, useEffect and useRef hooks that we will use to track the states and perform the side effects on certain actions.
Next, we imported all the components we created in the previous step of the tutorial and also imported the icons that you downloaded so we can use them in our components as source files.
The music player will use the M:SS
format to display the current and total time of the track, so we created the converter function for the time component.
Then we set the state for all of the variables we will be using in the app. We also looped through all of the tags from the playlist
object we received from the index.js
and pushed them into an array so we can display them at the top of the player.
On the initial load, we created a new audio object and set event listeners for loadeddata
, timeupdate
, volumechange
and ended
, so that when any of those happen the specific function is being triggered.
We also used side effects to set up the source for the active track when it is being changed, configured whether the track should be looped or the playlist should be shuffled when the current track ends and set up the track progress and volume level when the progress and volume knobs are dragged as well as filtered the tracks when any of the tags are selected.
Next, we created separate functions for the click events on the loop, previous, play, pause, next, and shuffle icons. These are all straight forward and the functionality is intuitive by the function names.
Finally, we put all of the imported components in the return block in the same order as we designed in the wireframe and passed in all of the props that were expected once we created each of the components individually.
Adding responsiveness
One last step for us to do is to add the responsiveness. We will be creating some CSS media rules for the following components: PlayerTemplate
, TitleAndTimeBox
, Title
, Time
, Progress
, ButtonsAndVolumeBox
, ButtonsBox
, Loop
and Shuffle
.
Media rules are usually added at the bottom of the stylesheets, so we will go through the styling files and add the following rules under the existing rules we wrote earlier:
Open the PlayerTemplate.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.wrapper {
padding: 0 20px;
}
}
We made sure that the player has some padding on the sides when used on the mobile devices.
Open the TitleAndTimeBox.module.css
and include the following style rules:
@media only screen and (max-width: 800px) {
.wrapper {
grid-template-columns: 1fr;
}
}
We set the title and time components to be displayed directly above each other on devices smaller than 800px
.
Open the Title.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.title {
width: 100%;
text-align: center;
}
}
We set the title to take all the available space and is centered for the mobile devices.
Open the Time.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.time {
text-align: center;
}
}
We centered the text of the time component for the mobile devices.
Open the Progress.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.container {
margin: 40px 0;
}
}
We set the top and bottom margins for the progress component on the mobile devices.
Open the ButtonsAndVolumeBox.module.css
and include the following style rules:
@media only screen and (max-width: 800px) {
.wrapper {
grid-template-columns: 1fr;
}
}
We set the bottom box and volume components to be displayed directly below each other on the screens smaller than 800px
.
Open the ButtonsBox.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.wrapper {
grid-template-columns: repeat(3, auto);
}
}
We made sure the button box uses the three-column layout with equal width for the mobile devices.
Open the Loop.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.loop {
display: none;
}
}
We hide the loop button on mobile devices to simplify the user interface.
Open the Shuffle.module.css
and include the following style rules:
@media only screen and (max-width: 600px) {
.shuffle {
display: none;
}
}
We hide the shuffle button on mobile devices to simplify the user interface.
After the addition of the media rules, we added the audio player should be fully responsible.
To test it, see if your dev server is still running in the terminal (if it is not run npm start
again), then open the browser on port http://localhost:3000 and press F12 to open the dev tools.
Try to resize the active view to see the player adjusting to different screen widths:
Deployment of the app
In order to make our app available to the public, first, we will need to push all the code to GitHub.
First, create a new GitHub accout (if you do not already have one), and Log In.
Select create a new repository from the menu, choose a Repository name (could be 'audio-player' or anything else you want), and click 'Create repository'.
To push the app to the newly created repository, switch back to your terminal/code editor and run the following commands (replace <username>
with your GitHub username and <reponame>
with the name of your repository):
git remote add origin https://github.com/<username>/<reponame>.git
git branch -M main
git push -u origin main
Then switch back to your GitHub and check if the files of your project have appeared in the repository you created. If so, you have successfully committed your code.
The final step is to deploy the app online. For that, we will use Vercel.
Head to Vercel, Create a new account (if you dont have one yet) and Log in.
Then create a New project. You will need to install Vercel for GitHub (access rights), so Vercel can view your Github repositories.
Now import your project from the 'Import Git Repository' panel.
Vercel will detect the project name, build commands, and root automatically, so you don't have to worry about that. The build process should not take longer than a minute.
Now go back to the Overview
tab of your project and click the Visit button, which will open the live URL of your project.
Congratulations, you have successfully deployed your music player!
From now on, every time you will push an update to GitHub, it will be automatically re-deployed on the Vercel, meaning your audio player will be in sync with the code on the GitHub.
Conclusion
In this tutorial we first defined the idea and functionality we would like to include in our audio player. Then we created a wireframe and put all of the features in the UI. The last step for the design was to pick a proper color scheme and find an appropriate font for the text to look good.
Then we went on and prepared the basis for our app to build upon. First, we set up the React app. Then we created some custom base files to render the player properly. Finally, we imported all the icons we used to control the audio playback.
In the technical implementation of the player, we first wrote all the individual components. Then we created the main app file of the player, imported all the components, and wrote the logic for the audio player. To improve the UI, we also included some media rules for the player to look great on mobile devices as well.
Finally, we pushed all the code to Github and from there deployed on Vercel, so it can be accessed from any device that has an internet connection.
During the process, I hope you got an insight into how the React apps are being built and what are some of the ways you can construct your file structure. Next time you will need to showcase some audio tracks on your website, you will know how to do it.
Writing has always been my passion and it gives me pleasure to help and inspire people. If you have any questions, feel free to reach out!
Connect me on Twitter, LinkedIn and GitHub!
Visit my Blog for more articles like this.
Top comments (60)
You should fork this and make a page where anyone can upload their music - no accounts, nothing like that and all music uploaded is downloadable by the public.
Would be nice to have an open area where music can be shared and downloaded.
Isn't that what Napster tried to do and got slammed for it because people were uploading copyrighted tracks?
Napster lacked good moderation
why cant everyone just stick to the script and be responsible :( that is a valid point.
That's another aspect that must be taken into consideration ππ―
Sounds like this has been done before and illegal
Thanks for the idea, might consider it ππ
Currently there is npm package, where users can create self hosted solutions for it π
If you want to find this project just go to madza.dev/music
Thanks for checking it out! β¨π―
This is just so freaking cool!
Also, your beats are dope. The bass on "Persistence" is really doing it for me.
Thanks for sharing!!
Thank you so much for taking a listen β€οΈπ―π΅
Awesome post! I really liked the wireframe structure, made it so clear to understand!
One question, it was difficult to manage all the states storing everything in the parent component? Did you thought about trying Redux? Thanks!
The default useState hooks were more than enough for this project πβ¨
Imho, redux or other external libs would be overkill π
Thank you for the kind words, means a lot π―π
This post breaks my browser lol.
Haha, this made my day β¨π
This is so detailed. Amazing!
Thank you so much ππ
Glad to hear you liked it β¨π₯³
I hope you win the hackathon! π
There are so many much more talented writers than me! π
My biggest win is to be in the same pool of the entries with them ππ
Can't say anything much but that's awesome!
Thanks for sharing! π
Thanks for the support! πβ¨π―
must need a lot of power to follow through out XD
Hahah, its not that complicated once you get going ππ
nice stuff
Thank you so much ππ
nice stuff
Thanks a lot ππ―
Nice post
Glad you liked it πβ¨