Creating a Cyberpunk-Themed Music Player with React, Web Audio API, and Canvas

Creating a Cyberpunk-Themed Music Player with React, Web Audio API, and Canvas

Introduction

Music has always played a significant role in our lives, bringing comfort, inspiration, and motivation. Whether you're enjoying your favorite tracks or relaxing after a tiring day, music has the power to transport us to a different place and time.

As a front-end developer, I decided to put my skills and creativity to the test by bringing the beauty of music to life on the web. Combining my love for visualizations and cyberpunk aesthetics, I was inspired to create a music player that was both functional and aesthetically pleasing.

In this article, I'll take you on a journey of building a cyberpunk-themed music player using React, the Web Audio API, and Canvas. By the end of this article, you will have a solid understanding of how to:

  • Build a music player with standard features such as:

    • Play

    • Pause

    • Next

    • Previous

    • Shuffle

    • Repeat

    • Volume control

  • Integrate the Web Audio API into your projects to enhance your applications with audio.

  • Create simple animated visual sketches using Canvas by adding an audio visualizer to the music player.

Here's a sneak peek at what we'll be building:

Preview of the music player

You can find the live demo of the music player here. The complete code for this project is also available on my GitHub here.

Prerequisites

Before we begin, kindly note that a basic knowledge of the following tools is required:

  • HTML

  • CSS

  • JavaScript

  • React v16.8+

Alright, with all that out of the way, let's begin!

Project Setup and Installation

Creating a new React project

Let's start by creating a new React project. To do this, run the following command in your terminal:

npm create vite@latest cyberpunk-music-player -- --template react

This will create a new React project in a folder named cyberpunk-music-player.

Installing required dependencies

The only new dependency needed for this project is react-bootstrap-icons. To install this library, navigate to the project directory and run the following command:

cd cyberpunk-music-player
npm install
npm i react-bootstrap-icons

We'll be using react-bootstrap-icons to incorporate icons into the UI of the project.

Setting the basis of the app

With the project setup and dependencies installed, it's time to configure the project structure. Navigate to the project directory and run the following command:

npm run dev

This starts our development server and lets us run our React app in real-time. We should have something similar to what we have below:

Next, let's delete all the files in the src directory, then create two new directories within the src folder:

  • components - This will store all our React components

  • styles - This is where we'll keep our application's CSS files.

Next, navigate to the src folder and create a new main.jsx file with the following code:

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App.jsx";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);

This sets up the entry point for our application and prepares our app for rendering.

Next, create a new App.jsx file and add the following code:

import React from "react";

const songs = [
    {
        id: 0,
        title: "Machinery of War",
        artist: "Evgeny Bardyuzha",
        image: "https://res.cloudinary.com/tropicolx/image/upload/v1675352152/music_app/machinery-of-war_fqu8z6.jpg",
        src: "https://res.cloudinary.com/tropicolx/video/upload/v1675218409/music_app/Evgeny_Bardyuzha_-_Machinery_of_War_fyaroh.mp3",
    },
    {
        id: 1,
        title: "Nova",
        artist: "2050",
        image: "https://res.cloudinary.com/tropicolx/image/upload/v1675351835/music_app/song-2_ljg2wd.jpg",
        src: "https://res.cloudinary.com/tropicolx/video/upload/v1675218406/music_app/2050_-_Nova_jp2ila.mp3",
    },
    {
        id: 2,
        title: "Medusa",
        artist: "Kryptos",
        image: "https://res.cloudinary.com/tropicolx/image/upload/v1675351585/music_app/song-3_ppgsaf.jpg",
        src: "https://res.cloudinary.com/tropicolx/video/upload/v1675218402/music_app/Kryptos_-_Medusa_yyj3nc.mp3",
    },
    {
        id: 3,
        title: "Artificial Intelligence",
        artist: "Lance Conrad",
        image: "https://res.cloudinary.com/tropicolx/image/upload/v1675351701/music_app/song-4_yaqewe.jpg",
        src: "https://res.cloudinary.com/tropicolx/video/upload/v1675218398/music_app/Lance_Conrad_-_Artificial_Intelligence_ioozhh.mp3",
    },
];

function App() {
    return (
        <></>
    )
}

export default App;

The App component returns an empty JSX Element for now, but soon it will be the driving force behind our music player. In the meantime, let's take a closer look at the songs constant. This constant holds all the information we need for each piece in our music player. As you can see, each object in the array represents a single song with its unique id, title, artist, image, and src (the source URL of the actual audio file).

The audio files themselves are hosted on Cloudinary. They were sourced from Artlist.io, a platform that provides tracks for creative projects. You can find and host the audio files with these platforms, but covering that process will complicate this guide. So, for now, we hard-coded the data in songs.

Before building the components, let's create an index.css file in the src folder for the global styles used throughout the app. Here is some code to get us started:

@import url("https://fonts.googleapis.com/css2?family=Bai+Jamjuree:wght@500&family=Barlow&family=Chakra+Petch:wght@600&display=swap");

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --primary-color: #ffee08;
    --caption-color: rgba(255, 238, 8, 0.48);
    --background-color: #030816;
}

body {
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

button {
    position: relative;
    background: none;
    border: none;
    outline: none;
    cursor: pointer;
}

.app {
    background: var(--background-color);
    height: 100svh;
    display: flex;
    justify-content: center;
    align-items: flex-end;
}

.layout {
    max-width: 24.375rem;
    padding: 2.5rem 0;
}

@media only screen and (min-width: 768px) {
    .app {
        align-items: center;
    }

    .layout {
        border: 2px solid var(--primary-color);
        padding: 2.5rem;
    }
}

@media only screen and (max-height: 640px) {
    .app {
        align-items: center;
    }
}

This sets up a clean and stylish base for our music player app.

Building the User Interface

Let's take a moment to determine which components we want to split our application into. Breaking down the app into smaller pieces helps to keep our code organized and easy to maintain.

Here are the components we'll be building:

  • SongImage - This showcases the song's image. It also features an SVG frame that serves as a visual highlight for the image.

  • Visualizer - This uses canvas to generate a cool visual representation of the music as it plays.

  • SongDetails - This contains all the essential details about the song. This component includes:

    • Song image (displayed through the SongImage component)

    • Song title

    • Song artist

    • Song visualizer (provided by the Visualizer component)

  • Volume - This is where users can adjust the volume of the music. It includes a volume button and a pop-up volume bar.

  • ExtraControls - This is a container for the Volume component and the Visualizer toggle button.

  • Progress - This displays a progress bar representing the song's current play head position. The progress bar also doubles as a seeker, allowing the user to jump to any point in the music. This component also contains the song's elapsed time and total duration.

  • PlayerControls - This houses the main controls for the music player. It consists of the following buttons:

    • play - to play the song

    • pause - to pause the song

    • next - to go to the next song

    • previous - to go to the previous song

    • shuffle - to shuffle the songs

    • repeat - to repeat the song

With these components in mind, let's head to the components directory and build our components individually.

SongImage.jsx

// components/SongImage.jsx
import React, { useState, useEffect, useRef } from "react";

const SongImage = ({ url }) => {
    const [imageLoaded, setImageLoaded] = useState(false);
    const imageRef = useRef(null);

    useEffect(() => {
        setImageLoaded(false);
        if (!imageRef.current) return;
        imageRef.current.setAttribute("href", url);
        imageRef.current.onload = () => setImageLoaded(true);
    }, [imageRef, url]);

    return (
        <svg
            width="310"
            height="267"
            viewBox="0 0 310 267"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
            xmlnsXlink="http://www.w3.org/1999/xlink"
        >
            <g clipPath="url(#clip0_254_11)">
                <path
                    d="M273.651 266.399L309.429 229.84C309.795 229.466 310 228.964 310 228.441V2.34701C310 1.2434 309.106 0.348373 308.002 0.34701L27.6036 0.000835899C27.1645 0.000293745 26.7374 0.144299 26.3882 0.410626L0.787054 19.9381C0.291064 20.3164 0 20.9045 0 21.5283V265C0 266.105 0.895431 267 2 267H40.735H272.221C272.759 267 273.274 266.783 273.651 266.399Z"
                    fill="url(#pattern0)"
                    stroke="#FFEE08"
                    strokeWidth="2"
                />
                <line
                    x1="17"
                    y1="1"
                    x2="1"
                    y2="1"
                    stroke="#FFEE08"
                    strokeWidth="2"
                    strokeLinecap="square"
                />
                <line
                    x1="0.285156"
                    y1="12"
                    x2="0.285157"
                    y2="1"
                    stroke="#FFEE08"
                    strokeWidth="2"
                    strokeLinecap="square"
                />
                <path
                    d="M295.817 266.915C295.655 267.079 295.441 267.182 295.211 267.206L284.743 268.315C283.813 268.413 283.269 267.296 283.92 266.624L309.712 240.013C310.363 239.341 311.498 239.851 311.427 240.784L310.603 251.633C310.585 251.87 310.484 252.092 310.317 252.261L295.817 266.915Z"
                    fill="#FFEE08"
                />
            </g>
            <defs>
                <pattern
                    id="pattern0"
                    patternContentUnits="objectBoundingBox"
                    width="1"
                    height="1"
                >
                    <use
                        xlinkHref={
                            imageLoaded ? "#image0_254_11" : "#image0_254_12"
                        }
                        transform="matrix(0.000390625 0 0 0.000453535 0 -0.0805243)"
                    />
                </pattern>
                <clipPath id="clip0_254_11">
                    <rect width="310" height="267" fill="white" />
                </clipPath>
                <image
                    id="image0_254_11"
                    width="2560"
                    height="2560"
                    ref={imageRef}
                    opacity={imageLoaded ? "1" : "0"}
                    className="songDetails__image"
                    attributeName="song image"
                />
                <image
                    id="image0_254_12"
                    width="2560"
                    height="2560"
                    xlinkHref="https://res.cloudinary.com/tropicolx/image/upload/v1675211310/music_app/default-song-image_tmekey.svg"
                    attributeName="default song image"
                />
            </defs>
        </svg>
    );
};

export default SongImage;

This component takes the song image url as its prop and displays it as an SVG image element. The useRef hook is used to store a reference to the song image.

The component also defines a second image element used as a placeholder. The two image Elements are switched based on the loading status of the song image. The useState and useEffect hooks manage the song image's loading state.

The SVG elements around the images act as a frame to add flair to the song image.

Okay, now we have our SongImage component ready, let's move on to the next piece.

SongDetails.jsx

// components/SongDetails.jsx
import React from "react";
import SongImage from "./SongImage";
import "../styles/SongDetails.css";

const SongDetails = ({ song }) => {
    return (
        <div className="songDetails">
            <div className="songDetails__imageContainer">
                <SongImage url={song.image} />
            </div>
            <div className="songDetails__info">
                <h3 className="songDetails__songName">{song.title}</h3>
                <h4 className="songDetails__artistName">{song.artist}</h4>
            </div>
        </div>
    );
};

export default SongDetails;

The SongDetails component uses a song prop to render the song title, song artist, and SongImage we created earlier. The rendered contents are then wrapped in div elements with appropriate class names for styling purposes.

Next, let's head to the styles folder and create a new file named SongDetails.css. Add the following CSS code:

.songDetails,
.songDetails__info {
    display: flex;
    flex-direction: column;
    align-items: center;
}

.songDetails__info {
    user-select: none;
    -webkit-user-select: none;
    -moz-user-select: none;
    margin-top: 1.625rem;
}

.songDetails__image {
    transition: opacity 0.25s ease-in;
}

.songDetails__artistName,
.songDetails__songName {
    text-align: center;
}

.songDetails__songName {
    font-family: "Chakra Petch";
    font-weight: 600;
    font-size: 1.5rem;
    line-height: 1.9375rem;
    color: var(--primary-color);
}

.songDetails__artistName {
    margin-top: 0.375rem;
    font-family: "Barlow";
    font-weight: 400;
    font-size: 1rem;
    line-height: 1rem;
    letter-spacing: 0.1em;
    color: var(--caption-color);
}

This styles the layout and appearance of the SongDetails and SongImage components.

Volume.jsx

// components/Volume.jsx
import React, { useEffect, useRef, useState } from "react";

import "../styles/Volume.css";

const useOnClickOutside = (ref, handler) => {
    useEffect(() => {
        const listener = (event) => {
            if (!ref.current || ref.current.contains(event.target)) {
                return;
            }
            handler(event);
        };

        document.addEventListener("mousedown", listener);
        document.addEventListener("touchstart", listener);

        return () => {
            document.removeEventListener("mousedown", listener);
            document.removeEventListener("touchstart", listener);
        };
    }, [ref, handler]);
};

const Volume = ({ value, onChange }) => {
    const [isOpen, setIsOpen] = useState(false);

    const divRef = useRef(null);
    useOnClickOutside(divRef, () => setIsOpen(false));

    const toggleIsOpen = () => {
        setIsOpen((prev) => !prev);
    };

    const getVolumeSvg = (volume) => {
        if (volume === 0) {
            return (
                <svg
                    width={30}
                    height={30}
                    viewBox="0 0 30 29"
                    fill="none"
                    xmlns="http://www.w3.org/2000/svg"
                >
                    <path
                        d="M12.5985 6.09254C12.9233 6.24865 13.1299 6.57713 13.1299 6.93751V21.9375C13.1299 22.2979 12.9233 22.6264 12.5985 22.7825C12.2737 22.9386 11.8881 22.8947 11.6067 22.6696L7.17603 19.125H2.81738C2.29962 19.125 1.87988 18.7053 1.87988 18.1875V10.6875C1.87988 10.1697 2.29962 9.75001 2.81738 9.75001H7.17603L11.6067 6.20544C11.8881 5.98032 12.2737 5.93642 12.5985 6.09254ZM11.2549 8.88809L8.09053 11.4196C7.9243 11.5526 7.71776 11.625 7.50488 11.625H3.75488V17.25H7.50488C7.71776 17.25 7.9243 17.3225 8.09053 17.4554L11.2549 19.9869V8.88809Z"
                        fill="#FFEE08"
                    />
                </svg>
            );
        } else if (volume < 53) {
            return (
                <svg
                    width={30}
                    height={30}
                    viewBox="0 0 30 30"
                    fill="none"
                    xmlns="http://www.w3.org/2000/svg"
                >
                    <path
                        d="M13.1299 7.50001C13.1299 7.13963 12.9233 6.81115 12.5985 6.65504C12.2737 6.49892 11.8881 6.54282 11.6067 6.76794L7.17603 10.3125H2.81738C2.29962 10.3125 1.87988 10.7322 1.87988 11.25V18.75C1.87988 19.2678 2.29962 19.6875 2.81738 19.6875H7.17603L11.6067 23.2321C11.8881 23.4572 12.2737 23.5011 12.5985 23.345C12.9233 23.1889 13.1299 22.8604 13.1299 22.5V7.50001ZM8.09053 11.9821L11.2549 9.45059V20.5494L8.09053 18.0179C7.9243 17.885 7.71776 17.8125 7.50488 17.8125H3.75488V12.1875H7.50488C7.71776 12.1875 7.9243 12.1151 8.09053 11.9821Z"
                        fill="#FFEE08"
                    />
                    <path
                        d="M18.802 15C18.802 17.3299 17.8576 19.4393 16.3307 20.9662L15.0049 19.6403C16.1925 18.4528 16.927 16.8121 16.927 15C16.927 13.1878 16.1925 11.5471 15.0049 10.3596L16.3307 9.03374C17.8576 10.5606 18.802 12.67 18.802 15Z"
                        fill="#FFEE08"
                    />
                </svg>
            );
        }
        return (
            <svg
                xmlns="http://www.w3.org/2000/svg"
                width={30}
                height={30}
                fill="none"
            >
                <path
                    d="M21.629 26.27c2.884-2.884 4.668-6.869 4.668-11.27s-1.784-8.385-4.668-11.269l-1.326 1.326c2.545 2.545 4.119 6.06 4.119 9.944s-1.574 7.399-4.119 9.944l1.326 1.326z"
                    fill="#fff"
                    fillOpacity={0}
                />
                <path
                    d="M18.978 23.618A12.15 12.15 0 0 0 22.547 15a12.15 12.15 0 0 0-3.57-8.618l-1.326 1.326A10.28 10.28 0 0 1 20.672 15a10.28 10.28 0 0 1-3.021 7.292l1.326 1.326zM18.797 15a8.41 8.41 0 0 1-2.471 5.966L15 19.64A6.54 6.54 0 0 0 16.922 15 6.54 6.54 0 0 0 15 10.36l1.326-1.326A8.41 8.41 0 0 1 18.797 15zm-5.672-7.5a.94.94 0 0 0-1.523-.732l-4.431 3.545H2.813a.94.94 0 0 0-.937.938v7.5a.94.94 0 0 0 .938.938h4.359l4.431 3.545a.94.94 0 0 0 1.523-.732v-15zm-5.039 4.482L11.25 9.45v11.099l-3.164-2.531c-.166-.133-.373-.205-.586-.205H3.75v-5.625H7.5a.94.94 0 0 0 .586-.205z"
                    fill="#ffee08"
                />
            </svg>
        );
    };

    return (
        <div ref={divRef} className="volumeContainer">
            <button onClick={toggleIsOpen} tabIndex={0} aria-label="Volume">
                <div className="volume__icon">{getVolumeSvg(value)}</div>
            </button>
            {isOpen && (
                <div className="volume">
                    <div className="volume__sliderContainer">
                        <input
                            type="range"
                            aria-label="Volume slider"
                            value={value}
                            min="0"
                            max="100"
                            className="volume__slider"
                            onChange={onChange}
                        />
                        <svg
                            width={54}
                            height={150}
                            viewBox="0 0 54 150"
                            fill="none"
                            xmlns="http://www.w3.org/2000/svg"
                        >
                            <path
                                d="M5.09434 111H0.999996V149H53V1H12.6352L6.82795 6.69967L0.999996 12.8964V82H5.09434H6.09434V83V110V111H5.09434Z"
                                fill="#040C1F"
                                stroke="#FFEE08"
                                strokeWidth={2}
                            />
                        </svg>
                    </div>
                </div>
            )}
        </div>
    );
};

export default Volume;

First, we defined a custom hook useOnClickOutside, that listens for clicks outside an element and executes a handler function if the click did not occur within the element.

Next, we defined our component function. It accepts two props:

  • value - the current volume level

  • onChange - the function that changes the volume level when a user drags or clicks the volume slider

The component uses a useRef hook to store a reference to the volume container. The reference value is then passed to the onClickOutside hook, which closes the volume slider when a user clicks outside its container.

The component uses a useState hook to monitor the volume slider's open/closed state and a button to toggle it, with a volume icon that adjusts based on the value prop.

Now, let's style this component. Head over to the styles folder and create a Volume.css file. Add the following code:

 .volumeContainer {
    position: relative;
}

.volume {
    position: absolute;
    top: -10.375rem;
    left: -0.75rem;
}

.volume__sliderContainer {
    width: 3.375rem;
    height: 9.375rem;
}

.volume__icon {
    cursor: pointer;
    width: 1.875rem;
    height: 1.875rem;
}

.volume__iconBlocker {
    position: absolute;
    top: 0;
    left: 0;
    width: 1.875rem;
    height: 1.875rem;
    background-color: transparent;
    cursor: pointer;
}

.volume__slider {
    -webkit-appearance: none;
    appearance: none;
    transform: rotate(-90deg);
    position: absolute;
    top: 4.375rem;
    left: -2.5rem;
    background: transparent;
    border: 2px solid var(--primary-color);
    width: 8.5rem;
    height: 0.5rem;
}

.volume__slider:focus {
    outline: none;
}

.volume__slider:focus::-webkit-slider-thumb {
    outline: 2px solid var(--primary-color);
}

.volume__slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 1.0625rem;
    height: 1.6875rem;
    outline: 2px solid var(--primary-color);
    border-radius: 0.3125rem;
    background: #f8ef00;
    cursor: pointer;
    position: relative;
}

We made user-friendly adjustments for easy volume control, including a clickable icon, styled slider, and convenient positioning.

ExtraControls.jsx

// components/ExtraControls.jsx
import React from "react";

const ExtraControls = ({ children }) => {
    return <div className="extraControls">{children}</div>;
};

export default ExtraControls;

This will be the wrapper for the Volume component and the Visualizer toggle button.

Now, let's head over to the index.css file in the src directory and add the following code to style the component:

...
.layout {
    ...
}

.extraControls {
    margin-top: 2.625rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

@media only screen and (min-width: 768px) {
    ...
}
...

We added some margin above the extra controls container to space it from the SongDetails component. We also added some styles to center the controls and display them at the edges of the container.

Progress.jsx

// components/Progress.jsx
import React from "react";

import "../styles/Progress.css";

const Progress = ({
    value,
    onChange,
    progressSeekStart,
    progressSeekEnd,
    timeElapsed,
    songLength,
}) => {
    return (
        <div className="progress">
            <input
                type="range"
                aria-label="Progress slider"
                value={value}
                min="0"
                max="100"
                className="progress__slider"
                onChange={onChange}
                style={{
                    background: `linear-gradient(90deg, var(--primary-color) ${Math.ceil(
                        value
                    )}%, transparent ${Math.ceil(value)}%)`,
                }}
                onTouchStart={progressSeekStart}
                onMouseDown={progressSeekStart}
                onTouchEnd={progressSeekEnd}
                onClick={progressSeekEnd}
            />
            <div className="progress__time">
                <span className="progress__timeElapsed">{timeElapsed}</span>
                <span className="progress__timeLength">{songLength}</span>
            </div>
        </div>
    );
};

export default Progress;

This component accepts the following props:

  • value - the song's current position

  • onChange - the function that is called when the progress bar is changed

  • progressSeekStart - the function called when the progress bar is touched or pressed

  • progressSeekEnd - the function called when the progress bar is released or clicked

  • timeElapsed - the time elapsed for the current song

  • songLength - the length of the current song

We returned a div that contains an input with a range type to display the progress slider. The value of this input is set to the value prop. We also set the minimum value to 0 and the maximum value to 100.

We gave the input some attributes set to either progressSeekStart or progressSeekEnd to control the user's interaction with the slider.

The background of the input is set to a linear gradient with the primary color on the left side and a transparent right side. The gradient is set to the value passed in from the props, with Math.ceil() used to ensure there is no decimal point.

Finally, we added a div inside the progress bar container to display the time elapsed and the song's length.

Next, let's go to the styles folder and create a Progress.css file. Add the following code:

.progress {
    margin-top: 2.625rem;
}

.progress__slider {
    -webkit-appearance: none;
    appearance: none;
    background: transparent;
    border: 2px solid rgba(255, 238, 8, 0.5);
    width: 100%;
    height: 0.5rem;
}

.progress__slider:focus {
    outline: none;
}

.progress__slider:focus::-webkit-slider-thumb {
    outline: 2px solid var(--primary-color);
}

.progress__slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 0.875rem;
    height: 0.875rem;
    outline: 2px solid var(--primary-color);
    background: #f8ef00;
    cursor: pointer;
    position: relative;
}

.progress__slider::-moz-range-thumb {
    width: 0.875rem;
    height: 0.875rem;
    outline: 2px solid var(--primary-color);
    background: #f8ef00;
    cursor: pointer;
    position: relative;
}

.progress__time {
    margin-top: 0.625rem;
    font-family: "Bai Jamjuree";
    font-weight: 500;
    font-size: 0.75rem;
    line-height: 0.9375rem;
    color: var(--primary-color);
    display: flex;
    justify-content: space-between;
    align-items: center;
    user-select: none;
    -webkit-user-select: none;
    -moz-user-select: none;
}

We customized the slider and improved the progress time styling for a cyberpunk theme. We also added a margin to separate the component and container.

PlayerControls.jsx

// components/PlayerControls.jsx
import React from "react";
import {
    PauseCircle,
    PlayCircle,
    Repeat,
    Repeat1,
    Shuffle,
    SkipEnd,
    SkipStart,
} from "react-bootstrap-icons";

import "../styles/PlayerControls.css";

const PlayerControls = ({
    isPlaying,
    toggleIsPlaying,
    prev,
    next,
    repeat,
    toggleRepeat,
    shuffle,
    toggleShuffle,
}) => {
    const centerControlSize = 47;
    const sideControlSize = 25;
    const color = "var(--primary-color)";

    return (
        <div className="playerControls">
            <button
                aria-label={shuffle ? "Disable shuffle" : "Enable shuffle"}
                className="shuffle"
                onClick={toggleShuffle}
            >
                <Shuffle color={color} size={sideControlSize} />
                {shuffle && <div className="dot" />}
            </button>
            <div className="playerControls__main">
                <button aria-label="Previous" onClick={prev}>
                    <SkipStart color={color} size={centerControlSize} />
                </button>
                <button
                    aria-label={isPlaying ? "Pause" : "Play"}
                    onClick={toggleIsPlaying}
                >
                    {!isPlaying && (
                        <PlayCircle color={color} size={centerControlSize} />
                    )}
                    {isPlaying && (
                        <PauseCircle color={color} size={centerControlSize} />
                    )}
                </button>
                <button aria-label="Next" onClick={next}>
                    <SkipEnd color={color} size={centerControlSize} />
                </button>
            </div>
            <button
                aria-label={repeat ? "Disable repeat" : "Enable repeat"}
                onClick={toggleRepeat}
            >
                {!repeat && <Repeat color={color} size={sideControlSize} />}
                {repeat && (
                    <>
                        <Repeat1 color={color} size={sideControlSize} />
                        <div className="dot" />
                    </>
                )}
            </button>
        </div>
    );
};

export default PlayerControls;

This component accepts the following props:

  • isPlaying - a boolean that indicates whether the song is currently playing or not

  • toggleIsPlaying - a callback function that toggles the isPlaying state.

  • prev - a function that plays the previous song

  • next - a function that plays the next song

  • repeat - a boolean that indicates whether repeat is enabled or not

  • toggleRepeat - a function that toggles the repeat state

  • shuffle - a boolean that indicates whether shuffle is enabled or not

  • toggleShuffle - a function that toggles the shuffle state

We created constants for the size and color of the button icons.

Next, we added the player control buttons and containers and the appropriate attributes to each button.

The button labels make the values assigned to each button attribute and their respective icons straightforward and intuitive.

The repeat and shuffle buttons contain a dot under their icons that will be shown only if their respective states are true.

Let's head over to the styles folder and create a PlayerControls.css file. Add the following code:

.playerControls {
    margin-top: 2.8125rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.playerControls__main {
    display: flex;
    align-items: center;
}

.playerControls__main > :not(:first-child) {
    margin-left: 1.4375rem;
}

.shuffle .dot {
    left: 0.5625rem;
}

These styles make a layout for the player control buttons and their icons.

Now, let's head over to our index.css file in the src directory and add the following styles for our dot elements:

...
.extraControls {
    ...
}

.dot {
    position: absolute;
    bottom: -2px;
    left: 0.675rem;
    width: 0.3125rem;
    height: 0.3125rem;
    border-radius: 50%;
    background-color: var(--primary-color);
}

@media only screen and (min-width: 768px) {
    ...
}
...

Visualizer.jsx

// components/Visualizer.jsx
import React from "react";

const Visualizer = () => {
    return <div id="canvasWrapper" />;
};

export default Visualizer;

This component returns a div with an id of canvasWrapper. We'll be using this wrapper to hold our canvas element that will be used to generate a visual representation of the music as it plays.

We're not configuring the actual visualizer functionality in this component just yet. We'll do that later in the article, using the canvas API to draw our visualizer onto a canvas element.

For now, let's add an outline for the canvas. Head to the index.css file and add the following code:

...
.dot {
    ...
}

#canvasWrapper canvas {
    outline: 2px solid var(--primary-color);
}

@media only screen and (min-width: 768px) {
    ...
}
...

Putting all the pieces together

So far, we've built all the main components for our app. Now it's time to put all our components together and integrate the logic of our music player.

The Web Audio API

Illustration of the Web Audio API

Before diving into the code, let's take a moment to discuss the primary tool we'll be utilizing for audio - the Web Audio API.

The Web Audio API is a high-level JavaScript API for processing and synthesizing audio in web applications. It provides a simple and powerful way to add sound to web applications. It also offers various features, such as creating sound nodes, controlling volume, filtering, and even synthesizing sound from scratch.

With the Web Audio API, we can add audio to our application by simply creating an HTML audio element, a native HTML element for embedding audio into a page.

The Web Audio API also provides an AudioContext object. Think of AudioContext as a command center or a workshop where all the sound activities on a web page occur.

Now, within this 'workshop', there's a special tool called the AnalyserNode. It lets us take a closer look at the audio and pull out detailed information about it. Why is this useful? We want to create cool visual effects based on the beat or rhythm of whatever song is currently playing; the AnalyserNode helps us do just that by giving us the data we need for such visualizations.

Feel free to refer to the official documentation to learn more about the Web Audio API.

Creating the Audio Element

We've introduced the Web Audio API and discussed how it provides various features for controlling and manipulating audio. Let's dive into the code and integrate the audio element into our React app!

In the App.js file, add the following code:

import React, { useEffect, useRef, useState } from "react";

const songs = [
    ...
];

function App() {
    const audioRef = useRef();
    const [playlist, setPlaylist] = useState(songs);
    const [currentSongIndex, setCurrentSongIndex] = useState(0);
    const [progress, setProgress] = useState(0);
    const [dragging, setDragging] = useState(false);
    const [timeElapsed, setTimeElapsed] = useState(0);
    const [songLength, setSongLength] = useState(0);
    const [songFinished, setSongFinished] = useState(false);
    const [repeat, setRepeat] = useState(false);

    const setTimeUpdate = () => {
        const audio = audioRef.current;
        const currentTime = audio.currentTime;
        const progress = currentTime
            ? Number(((currentTime * 100) / audio.duration).toFixed(1))
            : 0;
        setTimeElapsed(currentTime);
        !dragging && setProgress(progress);
    };

    const setLoadedData = async () => {
        const audio = audioRef.current;
        setTimeElapsed(audio.currentTime);
        setSongLength(audio.duration);
    };

    useEffect(() => {
        if (songFinished) {
            if (!repeat) next();
            setSongFinished(false);
        }
    }, [songFinished]);

    return (
        <div className="app">
            <audio
                src={playlist[currentSongIndex].src}
                ref={audioRef}
                onTimeUpdate={setTimeUpdate}
                onLoadedData={setLoadedData}
                onEnded={() => setSongFinished(true)}
                loop={repeat}
                crossOrigin="anonymous"
            ></audio>
        </div>
    );
}

export default App;

We set up several states using useState to manage the current song, song progress, time elapsed, song length, repeat mode, and whether the song is finished. We then created a reference to the audio element using the useRef hook and assigned it to the audioRef variable.

Next, we set up some event listeners for the audio element using the onTimeUpdate, onLoadedData, and onEnded properties.

The setTimeUpdate function updates the time elapsed and song progress based on the current time of the audio. The setLoadedData function updates the song length when the audio data is loaded. The onEnded event handler sets the songFinished state to true when the audio has ended.

We used the useEffect hook to listen to the songFinished state. When the song is finished and the repeat mode is off, the next function (which we will define later) is called to play the next song.

Finally, we render the audio element with the properties defined in the code, including the source of the audio, the reference created with the useRef hook, and the event listeners.

Implementing the Structure and Main Functionalities

In this section, we'll implement the structure and main functionalities of the music player. Add the following code in the App.js file:

import React, { useEffect, useRef, useState } from "react";
import { Soundwave } from "react-bootstrap-icons";

import ExtraControls from "./components/ExtraControls";
import PlayerControls from "./components/PlayerControls";
import Progress from "./components/Progress";
import SongDetails from "./components/SongDetails";
import Volume from "./components/Volume";

const songs = [
    ...
];

function App() {
    ...
    return (
        <div className="app">
            ...
            <div className="layout">
                <SongDetails
                    song={playlist[currentSongIndex]}
                />
                <ExtraControls>
                    <Volume
                        value={volume * 100}
                        onChange={(e) =>
                            setVolume(Number(e.target.value) / 100)
                        }
                    />
                    <button
                        aria-label={
                            visualizer
                                ? "Disable visualizer"
                                : "Enable visualizer"
                        }
                        onClick={() => setVisualizer((prev) => !prev)}
                    >
                        <Soundwave color="var(--primary-color)" size={25} />
                        {visualizer && <div className="dot" />}
                    </button>
                </ExtraControls>
                <Progress
                    value={progress}
                    onChange={(e) => {
                        setProgress(Number(e.target.value));
                    }}
                    progressSeekStart={() => setDragging(true)}
                    progressSeekEnd={progressSeekEnd}
                    timeElapsed={formatTime(timeElapsed)}
                    songLength={formatTime(songLength)}
                />
                <PlayerControls
                    next={next}
                    prev={prev}
                    isPlaying={isPlaying}
                    toggleIsPlaying={() =>
                        setIsPlaying((isPlaying) => !isPlaying)
                    }
                    shuffle={shuffle}
                    toggleShuffle={() => setShuffle((shuffle) => !shuffle)}
                    repeat={repeat}
                    toggleRepeat={() => setRepeat((repeat) => !repeat)}
                />
            </div>
        </div>
    );
}

export default App;

This code implements the overall structure of the music player using the components we've already created. Note how we've added the Volume component and visualizer toggle-button within the ExtraControls component as intended.

Next, let's add the functionality to these components to make the music player work as expected:

...
function App() {
    ...
    const [isPlaying, setIsPlaying] = useState(false);
    const [volume, setVolume] = useState(0.7);
    const [visualizer, setVisualizer] = useState(false);
    const [shuffle, setShuffle] = useState(false);
    const [shuffledPlaylist, setShuffledPlaylist] = useState(songs);

    ...
    const setLoadedData = async () => {
        ...
    };

    const playSong = async () => {
        await analyser.context.resume();
        setIsPlaying(true);
        await audioRef.current.play();
    };

    const shufflePlaylist = () => {
        setShuffledPlaylist((playlist) => {
            if (playlist.length === 1) return playlist;

            const newPlaylist = playlist.filter(
                (song) => song.id !== playlist[currentSongIndex].id
            );

            let shuffledPlaylist = newPlaylist.sort(() => Math.random() - 0.5);

            shuffledPlaylist = [
                playlist[currentSongIndex],
                ...shuffledPlaylist,
            ];
            return shuffledPlaylist;
        });
    };

    const next = () => {
        const currentSongId = playlist[currentSongIndex].id;
        const newPlaylist = shuffle ? shuffledPlaylist : songs;
        setPlaylist(newPlaylist);
        setCurrentSongIndex(() => {
            const currentSongIndex = newPlaylist.findIndex(
                (song) => song.id === currentSongId
            );
            const nextIndex = currentSongIndex + 1;
            const newIndex = nextIndex > newPlaylist.length - 1 ? 0 : nextIndex;
            return newIndex;
        });
        playSong();
    };

    const prev = () => {
        const currentSongId = playlist[currentSongIndex].id;
        const newPlaylist = shuffle ? shuffledPlaylist : songs;
        setPlaylist(newPlaylist);
        setCurrentSongIndex(() => {
            const currentSongIndex = newPlaylist.findIndex(
                (song) => song.id === currentSongId
            );
            const prevIndex = currentSongIndex - 1;
            const newIndex = prevIndex < 0 ? newPlaylist.length - 1 : prevIndex;
            return newIndex;
        });
        playSong();
    };

    const updateCurrentTime = (value) => {
        const audio = audioRef.current;
        const currentTime = (value * audio.duration) / 100;
        audio.currentTime = currentTime;
    };

    const progressSeekEnd = (e) => {
        updateCurrentTime(e.target.value);
        setDragging(false);
    };

    const formatTime = (time) => {
        const minutes = Math.floor(time / 60);
        const seconds = Math.floor(time - minutes * 60);
        return `${minutes}:${seconds < 10 ? "0" + seconds : seconds}`;
    };

    useEffect(() => {
        audioRef.current.volume = volume;
    }, [volume]);

    useEffect(() => {
        const playOrPause = async () => {
            if (isPlaying) {
                await analyser?.context?.resume();
                await audioRef.current.play();
            } else {
                audioRef.current.pause();
            }
        };

        playOrPause();
    }, [isPlaying, analyser?.context]);

    useEffect(() => {
        if (shuffle) shufflePlaylist();
    }, [shuffle]);

    useEffect(() => {
        ...
    }, [songFinished]);

    return (
        ...
    );
}

export default App;

We created the useState variables for the remaining states we need in our music player. We also added a function shufflePlaylist that shuffles the songs in our playlist. It uses the setShuffledPlaylist function to update the state of the shuffled playlist. If the shuffle state is true, the function will sort the songs randomly in the playlist.

The next and prev functions perform the following actions:

  • Update the current playlist to either the shuffledPlaylist or the songs constant depending on whether the shuffle state is true or false.

  • Use the setCurrentSongIndex function to update the current song's index in the new playlist.

  • Use the playSong function to start playing the previous or next song.

The updateCurrentTime function updates the current time of the song being played. It takes in a progress value and uses that to calculate the current time of the song based on its duration. The progressSeekEnd function is used to seek a specific time in the song by updating the value of the current time.

The formatTime function is used to format the time of the current song into a string of minutes and seconds.

We also see the use of useEffect hooks, which are used to update the state of our music player in response to changes in its properties. For example, we used useEffect to update the volume of the song being played, control the play and pause functionality of the music player, and shuffle the playlist when the shuffle state was set to true.

Integrating the Visualizer

Configuring the Audio Analyser

In this section, we will create and configure an audio analyzer. We'll use this to analyze the audio in our application and use the data obtained to configure our Visualizer component.

Let's go to our App.js file and add the following code:

...
const songs = [
    ...
];

class AudioAnalyser {
    constructor(audioElement) {
        this.context = new (window.AudioContext || window.webkitAudioContext)();
        this.source = this.context.createMediaElementSource(audioElement);
        this.analyserNode = this.context.createAnalyser();
        this.source.connect(this.analyserNode);
        this.analyserNode.connect(this.context.destination);
    }
}

function App() {
    ...
    const [analyser, setAnalyser] = useState(null);
    ...
    const setTimeUpdate = () => {
        ...
    };

    const setLoadedData = async () => {
        const audio = audioRef.current;
        !analyser && setAnalyser(new AudioAnalyser(audio));
        setTimeElapsed(audio.currentTime);
        setSongLength(audio.duration);
    };

    const playSong = async () => {
        ...
    };
    ...
    return (
        <div className="app">
            ...
            <div className="layout">
               <SongDetails
                    visualizer={visualizer}
                    source={analyser?.source}
                    analyser={analyser?.analyserNode}
                    currentSongIndex={currentSongIndex}
                    song={playlist[currentSongIndex]}
                />
                ...
            </div>
        </div>
    )
}

export default App;

First, we defined the AudioAnalyser class. This class sets the foundation for processing and visualizing audio data from our audio element. When a new instance of AudioAnalyser is created, it expects an audioElement (like an HTML <audio> tag) to be passed to it. This element is the source of our music or sound.

Inside the constructor, we do the following:

  • Set up AudioContext: We're initializing this.context. It's the main hub of the Web Audio API, an interface that lets us control, process, and analyze audio in the browser. We also account for potential browser compatibility issues by using window.webkitAudioContext as a fallback.

  • Creating Nodes: In the Web Audio API, "nodes" are building blocks that handle audio processing tasks. We initialize our first node this.source, which represents the audio source in our audio processing graph. Next, we initialize this.analyserNode, allowing us to extract frequency and timing information from our audio source. This is crucial for our visualizer.

  • Connecting Nodes: Here, we're defining the flow of our audio signal. First, we connect our audio source to the analyzer. Then, the analyzer connects to the audio context's destination (typically the device's speakers or headphones). This ensures that while we're analyzing the audio data, the audio also plays through the device's output.

Next, we modified the setLoadedData function to set the analyser to a new AudioAnalyser object if it is initially null, before setting the time elapsed and song length.

In the return statement of the App, we passed the analyser, source, and analyserNode properties of the AudioAnalyser object to the SongDetails component, which we'll work on later.

The Canvas API

Illustration of the Canvas API

Using the AudioAnalyser class, we've learned how to analyze audio data from an audio source. Now, we can use this data to configure our Visualizer component. To do this, we will use the canvas API.

The canvas API is a powerful tool that enables us to create and manipulate graphics on a web page. With this API, we can create custom graphics, animations, and even games.

In the context of our music player, we can use the canvas API to enable the visualizer component to display the audio data in real time. This component will allow users to see an audio waveform as it plays and provide a more engaging and interactive experience.

We can refer to the Mozilla Developer Network (MDN) documentation to get started with the canvas API.

The MDN documentation provides a comprehensive guide to the canvas element, including its properties, methods, and examples. It's an excellent resource for learning more about creating and manipulating graphics on a web page.

Creating the Visualizer component

Let's head over to the Visualizer.jsx file in our components directory. Add the following code:

import React, { useEffect } from "react";

let width = 310;
let height = 267;

const Visualizer = ({ analyser, source, currentSongIndex }) => {
    useEffect(() => {
        if (!analyser || !source) return;
        const container = document.querySelector("#canvasWrapper");
        const oldCanvas = document.querySelector("#canvasWrapper canvas");
        if (oldCanvas) oldCanvas.remove();

        let canvas = document.createElement("canvas");
        canvas.width = width;
        canvas.height = height;
        container?.appendChild(canvas);

        let ctx = canvas?.getContext("2d");

        analyser.fftSize = 256;

        let bufferLength = analyser.frequencyBinCount;

        let dataArray = new Uint8Array(bufferLength);

        let barWidth = (width / bufferLength) * 2.5;
        let barHeight;
        let x;

        function renderFrame() {
            requestAnimationFrame(renderFrame);
            x = 0;

            analyser.getByteFrequencyData(dataArray);

            ctx.fillStyle = "#030816";
            ctx.fillRect(0, 0, width, height);

            for (let i = 0; i < bufferLength; i++) {
                barHeight = dataArray[i];

                let t = i / bufferLength;
                let r = (1 - t) * 255 + t * 2;
                let g = (1 - t) * 238 + t * 215;
                let b = (1 - t) * 8 + t * 242;

                ctx.fillStyle =
                    "rgb(" +
                    Math.floor(r) +
                    "," +
                    Math.floor(g) +
                    "," +
                    Math.floor(b) +
                    ")";

                ctx.fillRect(x, height - barHeight, barWidth, barHeight);

                x += barWidth + 1;
            }
        }

        renderFrame();
    }, [currentSongIndex, analyser, source]);

    return <div id="canvasWrapper" />;
};

export default Visualizer;

We pass in the AnalyserNode, audio source, and currentSongIndex as props. When the component mounts or whenever these props change, we use the useEffect hook to create a canvas element and draw the audio data on it.

Here's what's happening in the useEffect code:

  • Checking and Cleaning: We first check if analyser and source are present. If not, we exit early. Then, we select the container for the canvas and remove any old canvas if it exists to prevent the stacking of multiple canvases.

  • Canvas Setup: A new canvas element is created, its width and height are set, and then it's appended to our container.

  • Analyser and Data Configuration: We retrieve the 2D rendering context from the canvas, set the fftSize for the analyzer (which affects the granularity of the visualized data), determine the buffer length, and initialize a Uint8Array to hold our frequency data.

  • Drawing the Visualizer: The core logic lies in the renderFrame function. This function continuously runs, updating the canvas in sync with screen refresh rates using requestAnimationFrame. We reset the x position, grab the frequency data, and draw a background rectangle on the canvas.

  • Dynamic Color Bars: For each frequency data point, we determine its height barHeight. We also use a gradient formula to compute a color based on the data point's position. This provides a colorful visual representation of the sound spectrum. The ctx.fillRect method then draws each bar of the visualizer. The x value determines the horizontal position of each bar, which increments by barWidth + 1 after every loop iteration.

  • Starting the Animation: We call the renderFrame function to begin our visualizer animation.

Now let's go to the SongDetails.jsx file and add in the Visualizer component:

import React from "react";

import SongImage from "./SongImage";
import Visualizer from "./Visualizer";

import "../styles/SongDetails.css";

const SongDetails = ({
    song,
    visualizer,
    source,
    analyser,
    currentSongIndex,
}) => {
    return (
        <div className="songDetails">
            <div className="songDetails__imageContainer">
                {!visualizer && <SongImage url={song.image} />}
                {visualizer && (
                    <Visualizer
                        source={source}
                        analyser={analyser}
                        currentSongIndex={currentSongIndex}
                    />
                )}
            </div>
            ...
        </div>
    );
};

export default SongDetails;

As you can see, we've added some additional props to the component, such as visualizer, source, analyser, and currentSongIndex.

Next, we modified the content of the image container to contain either the SongImage component or the Visualizer component depending on the value of the visualizer prop.

If visualizer is truthy, then we render the Visualizer component with the source, analyser, and currentSongIndex props. However, if visualizer is falsy, then we render the SongImage component like before.

Here is how the visualizer should look:

And that's it! You should now have a fully functional music player.

Conclusion

In this tutorial, we successfully built a cyberpunk-themed music player with React, Web Audio API, and Canvas. We began by identifying the desired features and creating a user interface mockup. Afterward, we established our application's foundation by setting up the React app.

We designed custom base files to ensure seamless rendering of the player and imported all the necessary icons required for audio playback control.

We prioritized the development of individual components before creating the central app file. We then imported all the components and programmed the audio player's functionality. Finally, we added a visualizer to our music player using the Canvas API.

You can find the complete code on the GitHub repository here.


If you found this article useful or learned something new, please consider showing your support by liking it and following me for updates on future posts.

You can also connect with me on Twitter, LinkedIn, and GitHub!

Till next time, happy coding!