DEV Community

Cover image for How I Built a Music Player to Showcase my own Tracks 🎡😍
Madza
Madza

Posted on • Originally published at madza.hashnode.dev

How I Built a Music Player to Showcase my own Tracks 🎡😍

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:

  1. 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.

  2. 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:

1652552261_1920x929.png

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:

  1. Play and Pause audio
  2. Next and Previous tracks
  3. Repeat the track
  4. Shuffle track order
  5. Progress slider
  6. Time left / Total time
  7. Volume slider
  8. Search track
  9. Filter tracks by genre
  10. 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:

1652552375_1372x865.png

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:

1652552428_1225x371.png

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.

1652553114_1168x86.png

  • The track title, search placeholder value, and the active playlist items will use Quicksand font.

1652553209_1129x97.png

  • The inactive playlist items will use the Poppins font.

1652553246_1213x102.png

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:

1652552949_1075x571.png

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>
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

1652554187_x.gif

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
Enter fullscreen mode Exit fullscreen mode

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!

1652601871_x.gif

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.

Discussion (58)

Collapse
greggcbs profile image
GreggHume

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.

Collapse
marissab profile image
Marissa B

Isn't that what Napster tried to do and got slammed for it because people were uploading copyrighted tracks?

Collapse
dumboprogrammer profile image
Tawhid

Napster lacked good moderation

Collapse
greggcbs profile image
GreggHume

why cant everyone just stick to the script and be responsible :( that is a valid point.

Collapse
madza profile image
Madza Author

That's another aspect that must be taken into consideration πŸ˜‰πŸ’―

Collapse
mdelong42 profile image
Matt DeLong

Sounds like this has been done before and illegal

Collapse
madza profile image
Madza Author

Thanks for the idea, might consider it πŸ‘πŸ˜‰
Currently there is npm package, where users can create self hosted solutions for it πŸ˜‰

Collapse
roman_22c01bcfb71 profile image
Roman

If you want to find this project just go to madza.dev/music

Collapse
madza profile image
Madza Author

Thanks for checking it out! βœ¨πŸ’―

Collapse
crinklywrappr profile image
Daniel Fitzpatrick

This post breaks my browser lol.

Collapse
madza profile image
Madza Author

Haha, this made my day βœ¨πŸ˜‰

Collapse
mtwn105 profile image
Amit Wani

This is so detailed. Amazing!

Collapse
madza profile image
Madza Author

Thank you so much πŸ‘πŸ˜‰
Glad to hear you liked it ✨πŸ₯³

Collapse
mtwn105 profile image
Amit Wani

I hope you win the hackathon! πŸ’–

Thread Thread
madza profile image
Madza Author

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 πŸš€πŸŽ‰

Collapse
alesbe profile image
alesbe

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!

Collapse
madza profile image
Madza Author

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 πŸ’―πŸ‘

Collapse
ambriel profile image
Ambriel Goshi

thank you

Collapse
madza profile image
Madza Author

My pleasure πŸ‘βœ¨

Collapse
snelson1 profile image
Sophia Nelson

nice stuff

Collapse
madza profile image
Madza Author

Thank you so much πŸ˜‰πŸ‘

Collapse
androbro profile image
koen de vulder

Thanks for sharing this! very clean and structured way of explaining. Kudos!

Collapse
madza profile image
Madza Author

My pleasure πŸ’―β€ Thanks a lot πŸ‘πŸ˜‰

Collapse
ikembakwem_ profile image
IKECHUKWU MBAKWEM

This is kinda cool, well done (Y)

Collapse
madza profile image
Madza Author

Thank you a lot! πŸ‘βœ¨πŸ’―

Collapse
zirkelc profile image
Chris

Really a great post! πŸ‘πŸ»

Collapse
madza profile image
Madza Author • Edited on

Means the world, thank you πŸ˜πŸ’―πŸ‘

Collapse
moose_said profile image
Mostafa Said

Great work Madza! I really hope you win the writathon ❀

Collapse
madza profile image
Madza Author

Thanks a lot, Mostafa! βœ¨πŸ‘
My inner win was to get this article done, hopefully other devs will find this useful πŸŽ‰πŸŽŠ

Collapse
brunoj profile image
Bruno

nice stuff

Collapse
madza profile image
Madza Author

Thanks a lot πŸ‘πŸ’―

Collapse
exenestecnico profile image
Ernesto

Loved the Crawling remix

Collapse
madza profile image
Madza Author • Edited on

Thanks for the listening πŸ’―β€οΈπŸ‘

Collapse
masonharper profile image
Mason Marper

Nice post

Collapse
madza profile image
Madza Author

Glad you liked it πŸ‘βœ¨

Collapse
michaeltharrington profile image
Michael Tharrington

This is just so freaking cool!

Also, your beats are dope. The bass on "Persistence" is really doing it for me.

Thanks for sharing!!

Collapse
madza profile image
Madza Author

Thank you so much for taking a listen β€οΈπŸ’―πŸŽ΅

Collapse
bobbyiliev profile image
Bobby Iliev

Great post Madza! Well done as always!

Collapse
madza profile image
Madza Author

Thank you so much, Bobby! πŸ‘βœ¨πŸ’―

Collapse
musadarj profile image
Sammy B.

Can't say anything much but that's awesome!
Thanks for sharing! πŸ™‚

Collapse
madza profile image
Madza Author

Thanks for the support! πŸ‘βœ¨πŸ’―

Collapse
spaboi profile image
SPABOI

I love the color theme. It’s a cool project. Keep up the good work!

Collapse
madza profile image
Madza Author

Thank you so much! πŸ’―πŸ‘

Collapse
ivis1 profile image
Ivan Isaac

Very helpful.

Collapse
madza profile image
Madza Author

Happy it helped πŸ‘βœ¨

Collapse
andrewbaisden profile image
Andrew Baisden

Cool guide.

Collapse
madza profile image
Madza Author

Thank you so much, Andrew πŸ‘πŸ’―

Collapse
foxonthe1 profile image
Fox Scarlett

This is great. Well coded and well presented. Your portfolio looks excellent. Inspiring! Thanks!

Collapse
madza profile image
Madza Author

Awesome to hear πŸ‘βœ¨

Collapse
appdesign profile image
AppDesign

amazing job!

Collapse
madza profile image
Madza Author

Thanks a lot πŸ‘πŸ’―

Collapse
marcio199226 profile image
oskar

Nice maybe I will use it as media player for my pwa app ytd.surge.sh for peoples who wants share their tracks through my app github.com/marcio199226/ytd/tree/v...

Collapse
madza profile image
Madza Author

Thanks for reading πŸ‘βœ¨πŸ’―

Collapse
tahsin52225 profile image
Tahsin Ahmed

Nice one , Will surely try out myself - Thank you for sharing

Collapse
madza profile image
Madza Author

Awesome to hear β€πŸ‘ Thanks a lot! πŸ’―πŸ˜‰

Collapse
madza profile image
Madza Author

Thank you so much, Leonid βœ¨πŸ‘
Music projects has always taken special place in my heart πŸ˜πŸ’―

Collapse
caominhdev profile image
Cao Quα»‘c Minh

Nice