Building a Custom Audio Player with React + TypeScript - A Step-by-Step Tutorial

Building a Custom Audio Player with React + TypeScript - A Step-by-Step Tutorial

Learn how to build a fully functional audio player with customizable controls and interfaces

Featured on Hashnode
Play this article

Introduction

When you're developing an application for music, podcasts, or any other application that involves loading and playing audio files, one of the initial challenges you encounter is figuring out how to create and customize your own audio player. This task can be quite daunting as it requires not only the technical knowledge to handle audio file formats and playback functionality but also the creative aspect of designing an appealing and user-friendly audio player interface. It may involve grappling with complex coding, integrating audio libraries or APIs, ensuring cross-platform compatibility, and dealing with potential issues such as buffering, latency, or audio quality. Additionally, you may need to consider user experience (UX) and user interface (UI) design principles to provide a seamless and visually appealing audio playback experience for your application's users.

In this tutorial, we will learn how to create a custom audio player using ReactPlayer, a popular React library for embedding audio and video in web applications. Custom audio player allows developers to design and implement their own user interface for audio playback, giving them more flexibility and control over the look and feel of their audio player. We will explore how to create custom play/pause buttons, volume controls, seek bars, durations for elapsed time and time left, muting, and looping audio, using React components and ReactPlayer's built-in props and event handlers.

To focus on the implementation part of the application, we will be using Tailwind CSS to add styles to our application. This should not be a limitation if you're on a different CSS framework, you can apply knowledge from the tutorial with plain CSS or any CSS framework of your choice.

Prerequisites

  • Basic knowledge of React and React Hooks

  • Basic knowledge of TypeScript

  • Familiarity with Tailwind is a plus but not required

  • Basic knowledge of Git, and package managers (npm or yarn)

Let's get started

Installation

Cloning the starter app

To get started, I have provided a starter app on GitHub. The application has been configured with Create React App, TypeScript, React icons, and Tailwind CSS. Open your terminal and run the following command:

git clone -b starter https://github.com/Cradoe/audio-player-tutorial.git

The above command will clone a copy of the starter app in the audio-player-tutorial directory. Run the following command, first, to go inside our application's directory and install the dependencies

cd audio-player-tutorial && npm install

or if you prefer yarn, use

cd audio-player-tutorial && yarn install

Directory structure

The cloned application has the structure that we need for our application. The files are literally empty but don't worry, we'll take them one after the other. And if you feel the need to restructure based on your project setup, that's okay, feel free.

Installing React Player

Next, let's install React Player, which is required for our audio player. Use the following command to install it:

npm install react-player

or

yarn install react-player

Once you have installed React Player, there are no further dependencies to install.

To start the application, run

npm start

or

yarn start

Initial page

If all goes well, you should see an interface that says welcome.

Now, let's dive deep into our player component and start building our custom audio player.

The AudioPlayer component

Open the AudioPlayer.tsx file and paste the following code:

// components/AudioPlayer.tsx

import ReactPlayer from "react-player";
import { useRef } from "react";

type Props = {
  url: string;
  title: string;
  author: string;
  thumbnail: string;
};

export const AudioPlayer = ({ url, title, author, thumbnail }: Props) => {
  const playerRef = useRef<ReactPlayer | null>(null);

  return (
    <div>
      <ReactPlayer
        ref={playerRef}
        url={url}
      />
    </div>
  );
};

The code above serves as the foundation of our AudioPlayer component. We have defined the basic props that will be passed down from its parent component, in our case, the App.tsx. The component is then rendered with a ref prop set to playerRef, which allows us to reference the ReactPlayer component instance that is rendered in the DOM. This enables us to access and manipulate the ReactPlayer component directly using the playerRef object for further customization and functionality.

Now, let's update our App.tsx file to use the AudioPlayer component. Replace the existing code in App.tsx with the following:

// App.tsx

import { AudioPlayer } from "./components/AudioPlayer";

const audio = {
  url: "https://storage.googleapis.com/media-session/elephants-dream/the-wires.mp3",
  title: "A sample audio title",
  author: "The Elephants Dream",
  thumbnail:
    "https://images.unsplash.com/photo-1511379938547-c1f69419868d",
};

const App = ()=> {
  return (
    <div className="container mx-auto text-center">
      <div className="md:w-1/2 lg:w-1/3 mx-auto">
        <AudioPlayer
          url={audio.url}
          title={audio.title}
          author={audio.author}
          thumbnail={audio.thumbnail}
        />
      </div>
    </div>
  );
}

export default App;

In the updated code, we have defined a sample object for our audio and passed its properties as props to our AudioPlayer component. This will render the AudioPlayer component in our app with the provided audio data.

To fully implement and control our audio player, we need to define some states and event handlers in our AudioPlayer.tsx file.

First, update your import to include useState

// components/AudioPlayer.tsx

import { useRef, useState } from "react";

States

Let's define our states

// components/AudioPlayer.tsx

const [playing, setPlaying] = useState<boolean>(false);
const [muted, setMuted] = useState<boolean>(false);
const [volume, setVolume] = useState<number>(0.5);
const [progress, setProgress] = useState<number>(0);
const [loop, setLoop] = useState<boolean>(false);
const [duration, setDuration] = useState<number>(0);

Add the above code immediately after the line where we defined our playerRef. We'll use each of these states to control the behavior and functionality of our audio player.

Event handlers

To manage events from our custom controls and the ReactPlayer component, let's define the following event handlers:

// components/AudioPlayer.tsx

// event handlers for custom controls

  const handlePlay = () => {
    setPlaying(true);
  };

  const handlePause = () => {
    setPlaying(false);
  };

  const handleVolumeChange = (newVolume: number) => {
    setVolume(newVolume);
  };

  const toggleMute = () => {
    setMuted((prevMuted) => !prevMuted);
  };

  const handleProgress = (state: any) => {
    setProgress(state.played);
  };

  const handleDuration = (duration: number) => {
    setDuration(duration);
  };

  const toggleLoop = () => {
    setLoop((prevLoop) => !prevLoop);
  };

These event handlers will handle various events triggered by the ReactPlayer component and custom controls in our audio player.

  • handlePlay and handlePause: These functions toggle the playing state of our player, allowing us to control when the audio plays and when it pauses.

  • handleVolumeChange: This function, which takes newVolume as a parameter, allows us to listen to changes in volume and adjust the player's volume accordingly based on the value received from our custom controls.

  • toggleMute: This function allows us to control the muting and unmuting of our audio player by toggling the value of the muted state.

  • onProgress: This function is bound to the onProgress event of the ReactPlayer component and is fired every time the player progresses in its playing mode. It accepts the state of the player, which can be used later in our custom controls to update the UI.

  • handleDuration: This function is bound to the onDuration event of the ReactPlayer component and accepts a parameter duration, which allows us to get details about the total elapsed time and time remaining for our audio. This information can be used to display the time left and elapsed time on the UI.

  • toggleLoop: This function toggles the value of our loop state, allowing users to control whether they want to loop over the same audio or not.

Binding states and event handlers to ReactPlayer

After we are done defining the states and event handlers that we would need, let's bind them the our ReactPlayer component. This would make it possible for us to manipulate the states and also actively listen for events from React Player. Update your ReactPlayer component with this:

// components/AudioPlayer.tsx 

<ReactPlayer
        ref={playerRef}
        url={url}
        playing={playing}
        volume={volume}
        muted={muted}
        loop={loop}
        onPlay={handlePlay}
        onPause={handlePause}
        onProgress={handleProgress}
        onDuration={handleDuration}
      />

AudioDetails component

To enhance the visual appearance of our AudioPlayer component, we can add styles to format how the title, thumbnail, and author of the song that is playing. In the AudioDetails.tsx file, you can use the following code:

// components/AudioDetails.tsx

type Props = {
  title: string;
  author: string;
  thumbnail: string;
};
export const AudioDetails = ({ title, author, thumbnail }: Props) => {
  return (
    <div className="bg-gray-800  rounded-t-xl px-5 py-8">
      <div className="flex space-x-4">
          <img
            src={thumbnail}
            alt=""
            width="150"
            height="150"
            className="flex-none rounded-lg bg-gray-100"
          />

        <div className="flex-1 w-2/3 space-y-3 grid justify-start">
          <p className="text-gray-200 text-lg leading-6 font-semibold truncate w-auto">
            {title}
          </p>

          <p className="text-cyan-500  text-sm leading-6 capitalize">
            {author}
          </p>
        </div>
      </div>
    </div>
  );
};

In the above code:

  • Props title, author, and thumbnail are defined as string type.

  • Tailwind CSS classes are used to format the appearance of the component.

To use the AudioDetails component in our AudioPlayer component, first let's import AudioDetails

// components/AudioPlayer.tsx

import {AudioDetails} from "./AudioDetails";

Then we can add the following code immediately after the ReactPlayer component in the AudioPlayer.tsx file:

// components/AudioPlayer.tsx

 <div className="shadow rounded-xl">
        <AudioDetails title={title} author={author} thumbnail={thumbnail} />
      </div>

PlayerControls component

Now let's define our custom controls and adjust the interface to meet our UI requirements. We'll be using the react-icons package as our icon library for most of our control buttons.

Component definition

Copy and paste the following code into the PlayerControls.tsx file:

// components/PlayerControls.tsx

import { useRef, useState, useMemo } from "react";

type Props = {
  playerRef: any;
  playing: boolean;
  loop: boolean;
  volume: number;
  muted: boolean;
  progress: number;
  duration: number;

  handlePlay: () => void;
  toggleMute: () => void;
  toggleLoop: () => void;
  handlePause: () => void;
  handleVolumeChange: (newVolume: number) => void;
};
export const PlayerControls = ({
  playerRef,
  loop,
  playing,
  volume,
  muted,
  progress,
  duration,
  handlePlay,
  toggleLoop,
  handlePause,
  handleVolumeChange,
  toggleMute,
}: Props) => {
  const [played, setPlayed] = useState<number>(0);
  const [seeking, setSeeking] = useState<boolean>(false);
  const playPauseButtonRef = useRef<HTMLButtonElement>(null);

  return (
    <div className="bg-gray-50  rounded-b-xl py-10">

    </div>
  );
};

In the above code:

  • Props playerRef, loop, playing, volume, muted, progress, and duration are defined with their respective types.

  • Function handlers such as handlePlay, handlePause, toggleMute, toggleLoop, and handleVolumeChange are passed as props.

  • States for played and seeking (jumping to a specific time point within the audio file) are defined using useState hook.

  • A ref is created using useRef hook for the play/pause button element.

Next, let's add the following codes to our PlayerControls component, right before the return statement:

Play and Pause

// components/PlayerControls.tsx

const togglePlayAndPause = () => {
    if (playing) {
      handlePause();
    } else {
      handlePlay();
    }
  };

Seeking

// components/PlayerControls.tsx

const handleSeekMouseDown = (e: any) => {
    setSeeking(true);
  };

const handleSeekChange = (e: any) => {
    setPlayed(parseFloat(e.target.value));
  };

const handleSeekMouseUp = (e: any) => {
    playerRef.current?.seekTo(parseFloat(e.target.value));
    setSeeking(false);
  };

Volume

// components/PlayerControls.tsx

const handleChangeInVolume =  (e: React.ChangeEvent<HTMLInputElement>) => {
   handleVolumeChange(Number(e.target.value));
  };

Progress

// components/PlayerControls.tsx

  useMemo(() => {
  setPlayed((prevPlayed) => {
    if (!seeking && prevPlayed !== progress) {
      return progress;
    }
    return prevPlayed;
  });
}, [progress, seeking]);

In the above code:

  • togglePlayAndPause function is defined to toggle between play and pause based on the playing prop passed to the component.

  • handleSeekMouseDown, handleSeekChange, and handleSeekMouseUp functions are defined to handle seeking behavior when the user interacts with the seek bar.

  • handleChangeInVolume function is defined to handle volume change when the user interacts with the volume control.

  • useMemo hook is used to optimize the update of the played state based on the progress prop passed to the component, and this happens only when the user is not in the process of seeking and the new progress value is not the same as the previous value.

Let's import some icons that we would use in our JSX.

// components/PlayerControls.tsx

import { CiPlay1, CiPause1 } from "react-icons/ci";
import { VscMute, VscUnmute } from "react-icons/vsc";
import { ImLoop } from "react-icons/im";

Also, let's update our JSX as follows:

// components/PlayerControls.tsx

 <div className="bg-gray-50  rounded-b-xl py-10">
      <div className="mb-8 flex gap-x-10 px-10">
        {/* duration: time played  */}
        <div className="text-xs text-gray-600">
          {/* <Duration seconds={duration * played} />  */}
        </div>

        {/* progress bar */}
        <div className="flex-1 mx-auto">
          <input
            type="range"
            min={0}
            max={0.999999}
            step="any"
            value={played}
            onMouseDown={handleSeekMouseDown}
            onChange={handleSeekChange}
            onMouseUp={handleSeekMouseUp}
            className="w-full h-4 rounded-lg appearance-none  bg-slate-400 accent-gray-900 focus:outline focus:outline-cyan-500 "
          />
        </div>
        {/* duration: time left */}
        <div className="text-xs text-gray-600 flex">
          -{/* <Duration seconds={duration * (1 - played)} /> */}
        </div>
      </div>

      <div className="grid grid-cols-3 items-center ">
         {/* loop button */}
        <div className="flex justify-center">
          <button
            className={`focus:outline focus:outline-cyan-500 font-bold hover:bg-gray-200 ${
              loop && "text-cyan-500"
            }`}
            onClick={toggleLoop}
          >
            <ImLoop />
          </button>
        </div>

        {/* play/pause button */}
        <div className="flex justify-center">
          <button
            ref={playPauseButtonRef}
            className="focus:outline focus:outline-cyan-500 border border-cyan-500 rounded-md p-4 hover:bg-gray-200"
            onClick={togglePlayAndPause}
          >
            {playing ? <CiPause1 /> : <CiPlay1 />}
          </button>
        </div>


        {/* volume control */}
        <div className="flex justify-center items-center gap-1">

          {/* mute button */}
          <button
            className="focus:outline focus:outline-cyan-500"
            onClick={toggleMute}
          >
            {muted ? <VscMute /> : <VscUnmute />}
          </button>

          {/* volume slider */}
          <input
            type="range"
            className="focus:outline focus:outline-cyan-500 w-[50%] h-2 rounded-lg  bg-slate-400 accent-gray-900"
            min={0}
            max={1}
            step={0.1}
            value={volume}
            onChange={handleChangeInVolume}
          />
        </div>
      </div>
    </div>

We made some updates to the JSX code by adding comments to indicate the start of each control element. We also defined functions to handle the controls, which are passed down from the AudioPlayer component.

Looping

Our updated JSX code has a button that calls the toggleLoop method. This method was passed to PlayerControls component, and it instructs the audio player to play the same audio over and over.

Mute

We also have a button in the updated JSX code that toggles muting. Users can use this to mute and unmute the player.


You must have noticed that we commented our durations, that's because we have decided to put that in another component.

Duration

Open up components -> Durations.tsx and paste the following code:

// components/Durations.tsx

export const Duration = ({ seconds }: { seconds: number }) => {
  return (
    <time dateTime={`P${Math.round(seconds)}S`}>{formatTime(seconds)}</time>
  );
};

const formatTime = (seconds: number) => {
  const date = new Date(seconds * 1000);
  const hh = date.getUTCHours();
  const mm = date.getUTCMinutes();
  const ss = padString(date.getUTCSeconds());
  if (hh) {
    return `${hh}:${padString(mm)}:${ss}`;
  }
  return `${mm}:${ss}`;
};

const padString = (string: number) => {
  return ("0" + string).slice(-2);
};

The code provided defines three functions that are used in the implementation of a Duration component, which displays the duration of an audio track in a human-readable format. It utilizes three functions: formatTime, padString, and Duration. The formatTime function converts the duration from seconds to hours, minutes, and seconds, while the padString function adds leading zeros to single-digit numbers.

Now, we can go back to our components -> PlayerControls.tsx file to import Duration and uncomment the following

// components/PlayerControls.tsx

import { Duration } from "./Duration";

// ...
{/* duration: time played  */}
<div className="text-xs text-gray-600">
  <Duration seconds={duration * played} />
</div>

// ...

{/* duration: time left */}
<div className="text-xs text-gray-600 flex">
  <Duration seconds={duration * (1 - played)} />
</div>

The Duration component is now imported and used to display the duration of the audio track in two places in the JSX code.

Oh Right! All our custom controls and interfaces are ready. But before we go, let's add a quick accessibility feature to our PlayerControls component. We would shift focus to the play button once the component is loaded, this would allow users to easily play the audio by pressing the Enter or Return keys.

Remember we defined const playPauseButtonRef = useRef<HTMLButtonElement>(null); earlier? Now, add the following code right before the return statement in our component.

// components/PlayerControls.tsx

// update your import to have useEffect
import { useEffect, useMemo, useRef, useState } from "react";

// ...

const PlayerControls = ({
  // props
}) => {
  // ... our states and functions

  // shifts focus to play button on component mount
  useEffect(() => {
    playPauseButtonRef.current?.focus();
  }, []);

  // ... rest of the component code ...

  return (
    // ... JSX code 
  );
};

The useEffect hook is used to shift the focus to the play button (playPauseButtonRef) once the PlayerControls component is mounted. This allows users to easily play the audio by pressing the Enter or Return key after the component is loaded.

Importing and using the Player Controls Update the JSX of our AudioPlayer component as follows:

// components/AudioPlayer.tsx

import { PlayerControls } from "./PlayerControls";
// components/AudioPlayer.tsx

// update the jsx

<div>
      <ReactPlayer
        ref={playerRef}
        url={url}
        playing={playing}
        volume={volume}
        muted={muted}
        loop={loop}
        onPlay={handlePlay}
        onPause={handlePause}
        onProgress={handleProgress}
        onDuration={handleDuration}
      />

      <div className="shadow rounded-xl">
        <AudioDetails title={title} author={author} thumbnail={thumbnail} />
        <PlayerControls
          playerRef={playerRef}
          playing={playing}
          volume={volume}
          muted={muted}
          progress={progress}
          duration={duration}
          loop={loop}
          // event handler props
          toggleMute={toggleMute}
          handlePlay={handlePlay}
          toggleLoop={toggleLoop}
          handlePause={handlePause}
          handleVolumeChange={handleVolumeChange}
        />
      </div>
    </div>

Recap of what we have:

  1. ReactPlayer: This component is used for playing audio and video. It is given the following props:

    • ref: A reference to the player instance for controlling playback.

    • url: The URL of the audio to be played.

    • playing: A boolean flag indicating whether the audio is currently playing or not.

    • volume: The volume level of the audio.

    • muted: A boolean flag indicating whether the audio is muted or not.

    • loop: A boolean flag indicating whether the audio should loop or not.

    • onPlay: An event handler function for when the audio starts playing.

    • onPause: An event handler function for when the audio is paused.

    • onProgress: An event handler function for tracking the progress of audio playback.

    • onDuration: An event handler function for getting the duration of the audio.

  2. AudioDetails: This component displays details about the audio such as title, author, and thumbnail. It is given the respective props for these details.

  3. PlayerControls: This is the custom control component that provides buttons for controlling audio playback. It is given the following props:

    • playerRef: A reference to the ReactPlayer instance for controlling playback.

    • playing: A boolean flag indicating whether the audio is currently playing or not.

    • volume: The volume level of the audio.

    • muted: A boolean flag indicating whether the audio is muted or not.

    • progress: The progress of the audio playback.

    • duration: The duration of the audio.

    • loop: A boolean flag indicating whether the audio should loop or not.

    • toggleMute: An event handler function for toggling the mute state of the audio.

    • handlePlay: An event handler function for playing the audio.

    • toggleLoop: An event handler function for toggling the loop state of the audio.

    • handlePause: An event handler function for pausing the audio.

    • handleVolumeChange: An event handler function for changing the volume of the audio.

The result

At this stage our component is ready, you should have an interface like the one below, feel free to use the controls.

Audio player app

Conclusion

In this tutorial, we successfully built a custom audio player using React and TypeScript. We started by cloning the starter project from GitHub and installing the necessary dependencies. We then created separate components for audio details and player controls and implemented their functionalities using React's useState as our state management and event-handling solution. We also added accessibility features for improved usability.

Throughout the process, we utilized various React concepts such as refs, props, state, and event handling, as well as TypeScript's type annotations for static typing and improved code reliability. We also leveraged popular libraries like ReactPlayer for audio playback.

By following this step-by-step guide, you can create a fully functional audio player with customizable controls and interfaces, allowing you to integrate audio playback seamlessly into your web applications. With further customization and enhancements, you can adapt this audio player to suit your specific project requirements and provide an enhanced audio experience for your users.

You can find the finished project on GitHub, which serves as a valuable resource for reference and further exploration.

Building a custom audio player in React with TypeScript can be a rewarding and valuable addition to your web development skills. It opens up possibilities for creating engaging and interactive audio-driven applications and empowers you with the ability to implement custom audio players tailored to your project needs. Happy coding!

References

  1. ReactPlayer library

  2. Official React TypeScript documentation

  3. GitHub repository for the finished project

These references provide links to React Player package and the finished project on GitHub. They can be helpful for further learning, troubleshooting, and customization of your custom audio player.