DEV Community

Cover image for Build a Spotify clone with React and ts-audio
Matt Angelosanto for LogRocket

Posted on • Updated on • Originally published at blog.logrocket.com

Build a Spotify clone with React and ts-audio

Written by Fortune Ikechi✏️

Music players are devices or applications that allow you to listen to audio files and recordings. There are many music players available, but in this article, we’ll build a clone of the popular music streaming service, Spotify, using React and ts-audio.

You might expect that this tutorial would use the Spotify API, however, Spotify and other music databases do not provide a streamable link or URL in their response body. The Spotify API does provide a preview URL, but the duration of the songs is limited to only 30 seconds, and that isn't enough for our example. Therefore, we won't be using the Spotify API or making any requests to any music API or databases.

Instead, we’ll work with dummy data consisting of songs and image art. However, if you venture across an API with a streamable link, you can also apply the methods used in this article. You can find the complete code for this tutorial at the GitHub repo. Let’s get started!

What is ts-audio?

ts-audio is an agnostic library that makes the AudioContext API easier to interact with. ts-audio provides you with methods like play, pause, and more, and it allows you to create playlists. ts-audio offers the following features:

  • Includes a simple API that abstracts the complexity of the AudioContext API
  • Offers cross-browser support
  • Makes it easy to create an audio playlist
  • Works with any language that compiles into JavaScript

Building a Spotify clone with ts-audio

Let's start by creating a new React app with the command below:

npx create-react-app ts-audio
Enter fullscreen mode Exit fullscreen mode

If you’re using Yarn, run the command below:

yarn create react-app ts-audio
Enter fullscreen mode Exit fullscreen mode

For the rest of the tutorial, I’ll use Yarn. Next, we install the ts-audio package as follows:

yarn add ts-audio
Enter fullscreen mode Exit fullscreen mode

At its core, ts-audio has two components, Audio and AudioPlaylist. The components are functions that we can call with specific parameters.

Using the Audio component

The Audio component allows us to pass in a single song to be played. It also provides us with certain methods like play(), pause(), stop(), and more:

// App.js

import Audio from 'ts-audio';
import Lazarus from './music/Lazarus.mp3';

export default function App() {
  const audio = Audio({
    file: Lazarus
  })

  const play = () => {
    audio.play()
  }

    const pause = () => {
    audio.pause()
  }

    const stop = () => {
    audio.stop()
  }

  return (
    <>
      <button onClick={play}>Play</button>
      <button onClick={pause}>Pause</button>
      <button onClick={stop}>Stop</button>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the code block above, we imported the Audio component from ts-audio and the song we want to play. We created an audio instance, set it to the imported Audio component, and then passed the imported music to the file parameter exposed by the Audio element. We took advantage of the methods provided to us by ts-audio, like play() and pause(), then passed them through functions to the buttons.

Using the AudioPlaylist component

The AudioPlaylist component allows us to pass in multiple songs, but they have to be in an array, otherwise ts-audio won't play them. The AudioPlaylist component provides us with methods like play(), pause(), stop(), next(), and prev().

The code block below is an example of how to use the AudioPlaylist component:

// App.js

import { AudioPlaylist } from 'ts-audio';
import Lazarus from './music/Lazarus.mp3';
import Sia from './music/Sia - Bird Set Free.mp3';

export default function App() {
  const playlist = AudioPlaylist({
    files: [Lazarus, Sia]
  })

  const play = () => {
    playlist.play()
  }

  const pause = () => {
    playlist.pause()
  }

  const next = () => {
    playlist.next()
  }

  const previous = () => {
    playlist.prev()
  }

  const stop = () => {
    playlist.stop()
  }

  return (
    <>
      <button onClick={play}>Play</button>
      <button onClick={pause}>Pause</button>
      <button onClick={next}>Next</button>
      <button onClick={prev}>Prev</button>
      <button onClick={stop}>Stop</button>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

The music player will have the following functionalities:

  • Change the artist to the current song's artist whenever we click on either next or previous
  • Change the image to the current song's image
  • Change the song title to the current song

In the src folder, create two folders called images and music, respectively. Navigate to the images folder and paste any photos you might need. In the music folder, you can paste any audio files that you want to use.

In the following GitHub repos, you can get the image files used in this tutorial and obtain the audio files. Next, import songs and images into App.js as follows:

import { AudioPlaylist } from 'ts-audio';

// Music import
import Eyes from './music/01\. Jon Bellion - Eyes To The Sky.mp3';
import Mood from './music/24kGoldn-Mood-Official-Audio-ft.-Iann-Dior.mp3';
import Audio from './music/audio.mp3';
import Broken from './music/Cant Be Broken .mp3';
import Lazarus from './music/Lazarus.mp3';
import Sia from './music/Sia - Bird Set Free.mp3';
import Nobody from './music/T-Classic-Nobody-Fine-Pass-You.mp3';
import Yosemite from './music/Yosemite.mp3';

// Pictures import
import EyesImg from './images/Eyes to the sky.jpeg';
import MoodImg from './images/mood.jpeg';
import AudioImg from './images/lana.jpeg';
import BrokenImg from './images/lil wayne.jpeg';
import LazarusImg from './images/dave.jpeg';
import SiaImg from './images/sia.jpeg';
import NobodyImg from './images/nobody.jpeg';
import YosemiteImg from './images/travis.jpeg';

export default function App() {
  const songs =  [
      {
        title: 'Eyes to the sky',
        artist: 'Jon Bellion',
        img_src: EyesImg,
        src: Eyes,
      },
      {
        title: 'Lazarus',
        artist: 'Dave',
        img_src: LazarusImg,
        src: Lazarus,
      },
      {
        title: 'Yosemite',
        artist: 'Travis scott',
        img_src: YosemiteImg,
        src: Yosemite,
      },
      {
        title: 'Bird set free',
        artist: 'Sia',
        img_src: SiaImg,
        src: Sia,
      },
      {
        title: 'Cant be broken',
        artist: 'Lil wayne',
        img_src: BrokenImg,
        src: Broken,
      },
      {
        title: 'Mood',
        artist: '24kGoldn',
        img_src: MoodImg,
        src: Mood,
      },
      {
        title: 'Nobody fine pass you',
        artist: 'T-Classic',
        img_src: NobodyImg,
        src: Nobody,
      },
      {
        title: 'Dark paradise',
        artist: 'Lana Del Ray',
        img_src: AudioImg,
        src: Audio,
      },
    ]

  const playlist = AudioPlaylist({
      files: songs.map((song) => song.src),
    });

  const handlePlay = () => {
    playlist.play();
  };

  const handlePause = () => {
    playlist.pause();
  };

  const handleSkip = () => {
    playlist.next();
  };

  const handlePrevious = () => {
    playlist.prev();
  };

  return (
    <>
      <button onClick={handlePlay}>Play</button>
      <button onClick={handlePause}>Pause</button>
      <button onClick={handleSkip}>Next</button>
      <button onClick={handlePrevious}>Prev</button>     
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code block above, we imported the songs and images. Next, we created a song array containing objects. Each object has a title, artist, img_src for the imported images, and src for the imported songs.

After that, we mapped through the song array to get to the song's src, which we passed into the files parameter. Remember, we have to pass it in as an array, but then the map() method creates a new array from calling a function. Therefore, we can pass it to the files parameter.

We also created our methods and passed them to the various buttons. We’ll create a Player.js file to handle the buttons while we take care of the functionality in App.js:

// Player.js

export default function Player({ play, pause, next, prev }) {
  return (
    <div className="c-player--controls">
      <button onClick={play}>Play</button>
      <button onClick={pause}>Pause</button>
      <button onClick={next}>Next</button>
      <button onClick={prev}>Previous</button> 
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code block above, we created a Player.js file, then caught the props coming from App.js, and finally passed them into the buttons.

Creating the functionalities

To create the functionalities for our application, we import useState to get the current index of the song. We then set the image to the current photo, the artist to the current artist, and the title to the current title:

// App.js

import React, { useState } from 'react';
import Player from './Player';
import { AudioPlaylist } from 'ts-audio';
// Music import

// Pictures import

export default function App() {
  const [currentSong, setCurrentSong] = useState(0);
  const [isPlaying, setIsPlaying] = useState(false);

  // Songs Array

  const playlist =AudioPlaylist({
      files: songs.map((song) => song.src),
    });

  const handlePlay = () => {
    playlist.play();
    setIsPlaying(true);
  };

  const handlePause = () => {
    playlist.pause();
    setIsPlaying(false);
  };

  const handleSkip = () => {
    playlist.next();
    setIsPlaying(true);
    setCurrentSong(
      (currentSong) => (currentSong + 1 + songs.length) % songs.length
    );
  };

  const handlePrevious = () => {
    playlist.prev();
    setIsPlaying(true);
    setCurrentSong(
      (currentSong) => (currentSong - 1 + songs.length) % songs.length
    );
  };
  return (
    <>
      <div className="App">
        <div className="c-player">
          <div className="c-player--details">
            {' '}
            <div className="details-img">
              {' '}
              <img src={songs[currentSong].img_src} alt="img" />
            </div>
            <h1 className="details-title">{songs[currentSong].title}</h1>
            <h2 className="details-artist">{songs[currentSong].artist}</h2>
          </div>
          <Player
            play={handlePlay}
            pause={handlePause}
            isPlaying={isPlaying}
            setIsPlaying={setIsPlaying}
            next={handleSkip}
            prev={handlePrevious}
          />
        </div>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We created a state event and set it to zero. When we click the next button, we set the state to the sum of the remainder of the current state, one, and the song's length, divided by the song's length:

currentSong + 1 + songs.length) % songs.length
Enter fullscreen mode Exit fullscreen mode

When we click the previous button, we set the state to the remainder of the current song, minus one, plus the song's length divided by the song's length:

currentSong - 1 + songs.length) % songs.length
Enter fullscreen mode Exit fullscreen mode

We also created a state event that checks if the song is playing or not, and then we passed it as props to the Player component. Finally, we handled the functionalities for changing the image, artists, and song title.

When we start the application, everything seems to work; the images change when clicking on the next button. However, the songs playing don't match the pictures and artist names displayed on the screen. Sometimes, two or more songs are playing simultaneously.

Problem solving: Mismatched song details

When we click on the next or previous buttons, we are recalculating values and effectively causing a re-render. To stop this, we wrap the song array and the created instance of the playlist in a useMemo Hook, as seen below:

// App.js

import React, { useState, useMemo } from 'react';
import Player from './Player';
import { AudioPlaylist } from 'ts-audio';
// Music import

// Pictures import

export default function App() {
  const [currentSong, setCurrentSong] = useState(0);

  const songs = useMemo(
    () => [
      {
        title: 'Eyes to the sky',
        artist: 'Jon Bellion',
        img_src: EyesImg,
        src: Eyes,
      },
      {
        title: 'Lazarus',
        artist: 'Dave',
        img_src: LazarusImg,
        src: Lazarus,
      },
      {
        title: 'Yosemite',
        artist: 'Travis scott',
        img_src: YosemiteImg,
        src: Yosemite,
      },
      {
        title: 'Bird set free',
        artist: 'Sia',
        img_src: SiaImg,
        src: Sia,
      },
      {
        title: 'Cant be broken',
        artist: 'Lil wayne',
        img_src: BrokenImg,
        src: Broken,
      },
      {
        title: 'Mood',
        artist: '24kGoldn',
        img_src: MoodImg,
        src: Mood,
      },
      {
        title: 'Nobody fine pass you',
        artist: 'T-Classic',
        img_src: NobodyImg,
        src: Nobody,
      },
      {
        title: 'Dark paradise',
        artist: 'Lana Del Ray',
        img_src: AudioImg,
        src: Audio,
      },
    ],
    []
  );

  const playlist = useMemo(() => {
    return AudioPlaylist({
      files: songs.map((song) => song.src),
    });
  }, [songs]);
Enter fullscreen mode Exit fullscreen mode

The useMemo Hook effectively caches the value so that it doesn't need to be recalculated and therefore doesn't cause a re-render.

Adding styling

We’ll use icons from Font Awesome Icons in this tutorial. You can install the Font Awesome package using the commands below:

yarn add @fortawesome/fontawesome-svg-core
yarn add @fortawesome/free-solid-svg-icons
yarn add @fortawesome/react-fontawesome
Enter fullscreen mode Exit fullscreen mode

Copy and paste the code below into the Player.js file:

// Player.js

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlay, faPause, faForward, faBackward } from '@fortawesome/free-solid-svg-icons';
export default function Player({ play, pause, next, prev, isPlaying, setIsPlaying }) {
  return (
    <div className="c-player--controls">
      <button className="skip-btn" onClick={prev}>
        <FontAwesomeIcon icon={faBackward} />
      </button>
      <button
        className="play-btn"
        onClick={() => setIsPlaying(!isPlaying ? play : pause)}
      >
        <FontAwesomeIcon icon={isPlaying ? faPause : faPlay} />
      </button>
      <button className="skip-btn" onClick={next}>
        <FontAwesomeIcon icon={faForward} />
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code block above, we get the props from the App.js file, then handle them inside the Player.js file. For styling, copy and paste the code below into your index.css file:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Fira Sans', sans-serif;
}
body {
  background-color: #ddd;
}
.App {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  max-width: 100vw;
}
.c-player {
  display: block;
  background-color: #0a54aa;
  max-width: 400px;
  display: block;
  margin: 0px auto;
  padding: 50px;
  border-radius: 16px;
  box-shadow: inset -6px -6px 12px rgba(0, 0, 0, 0.8),
    inset 6px 6px 12px rgba(255, 255, 255, 0.4);
}
.c-player > h4 {
  color: #fff;
  font-size: 14px;
  text-transform: uppercase;
  font-weight: 500;
  text-align: center;
}
.c-player > p {
  color: #aaa;
  font-size: 14px;
  text-align: center;
  font-weight: 600;
}
.c-player > p span {
  font-weight: 400;
}
.c-player--details .details-img {
  position: relative;
  width: fit-content;
  margin: 0 auto;
}
.c-player--details .details-img img {
  display: block;
  margin: 50px auto;
  width: 100%;
  max-width: 250px;
  border-radius: 50%;
  box-shadow: 6px 6px 12px rgba(0, 0, 0, 0.8),
    -6px -6px 12px rgba(255, 255, 255, 0.4);
}
.c-player--details .details-img:after {
  content: '';
  display: block;
  position: absolute;
  top: -25px;
  left: -25px;
  right: -25px;
  bottom: -25px;
  border-radius: 50%;
  border: 3px dashed rgb(255, 0, 0);
}
.c-player--details .details-title {
  color: #eee;
  font-size: 28px;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8),
    -2px -2px 4px rgba(255, 255, 255, 0.4);
  text-align: center;
  margin-bottom: 10px;
}
.c-player--details .details-artist {
  color: #aaa;
  font-size: 20px;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8),
    -2px -2px 4px rgba(255, 255, 255, 0.4);
  text-align: center;
  margin-bottom: 20px;
}
.c-player--controls {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 30px;
}
.c-player--controls .play-btn {
  display: flex;
  margin: 0 30px;
  padding: 20px;
  border-radius: 50%;
  box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.8),
    -4px -4px 10px rgba(255, 255, 255, 0.4),
    inset -4px -4px 10px rgba(0, 0, 0, 0.4),
    inset 4px 4px 10px rgba(255, 255, 255, 0.4);
  border: none;
  outline: none;
  background-color: #ff0000;
  color: #fff;
  font-size: 24px;
  cursor: pointer;
}
.c-player--controls .skip-btn {
  background: none;
  border: none;
  outline: none;
  cursor: pointer;
  color: rgb(77, 148, 59);
  font-size: 18px;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we’ve learned about ts-audio, an agnostic, easy-to-use library that works with theAudioContext API. We learned about ts-audio's methods and how it makes it easier to work with audio files. Finally, we learned how to build a working music player using ts-audio.


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.

Top comments (0)