Skip to content
Build a Music Player App with React and Web Audio API (Step by Step)

Build a Music Player App with React and Web Audio API (Step by Step)

DodaTech Updated Jun 20, 2026 8 min read

Build a browser-based music player using React and the Web Audio API with playback controls, a playlist, seek bar, volume slider, and album art display.

What You’ll Build

You’ll build a fully functional music player that runs entirely in the browser. Users can upload audio files, play/pause tracks, skip through a playlist, seek to any position, adjust volume, and see album art. The Web Audio API handles low-level audio processing, giving you precise control over playback — the same technology powers tools like DodaZIP’s audio preview feature.

Why the Web Audio API Matters

The <audio> element is fine for simple playback, but the Web Audio API gives you programmatic control: audio nodes, real-time analysis, effects, visualizations, and precise timing. Music players, DAWs (Digital Audio Workstations), game audio engines, and voice chat apps all use it. Understanding it unlocks browser-based audio processing that you cannot achieve with HTML tags alone.

Prerequisites

Step 1: Setup the Project

npx create-react-app music-player
cd music-player
npm start

Clean out the boilerplate. We’ll build everything from scratch inside src/.

Project structure:

src/
├── App.js
├── App.css
├── components/
│   ├── Player.js
│   ├── Playlist.js
│   ├── SeekBar.js
│   └── VolumeControl.js
└── hooks/
    └── useAudioPlayer.js

Step 2: The Audio Player Hook

This custom hook encapsulates all Web Audio API logic:

// src/hooks/useAudioPlayer.js
import { useState, useRef, useCallback, useEffect } from 'react';

export function useAudioPlayer() {
  const [tracks, setTracks] = useState([]);
  const [currentIndex, setCurrentIndex] = useState(-1);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolume] = useState(0.7);

  const audioContextRef = useRef(null);
  const sourceRef = useRef(null);
  const gainNodeRef = useRef(null);
  const audioElementRef = useRef(null);
  const animationRef = useRef(null);

  const getAudioContext = useCallback(() => {
    if (!audioContextRef.current) {
      audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
    }
    return audioContextRef.current;
  }, []);

  const loadTrack = useCallback((index) => {
    const ctx = getAudioContext();
    if (sourceRef.current) sourceRef.current.disconnect();

    const track = tracks[index];
    if (!track) return;

    const audio = new Audio(track.url);
    audioElementRef.current = audio;

    const source = ctx.createMediaElementSource(audio);
    const gainNode = ctx.createGain();
    gainNode.gain.value = volume;

    source.connect(gainNode);
    gainNode.connect(ctx.destination);

    sourceRef.current = source;
    gainNodeRef.current = gainNode;
    setCurrentIndex(index);

    audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
    audio.addEventListener('timeupdate', () => setCurrentTime(audio.currentTime));
    audio.addEventListener('ended', () => nextTrack());
  }, [tracks, volume, getAudioContext, nextTrack]);

  const togglePlay = useCallback(() => {
    if (tracks.length === 0) return;

    if (isPlaying) {
      audioElementRef.current?.pause();
    } else {
      if (currentIndex === -1) {
        loadTrack(0);
      }
      audioElementRef.current?.play().then(() => {
        if (audioContextRef.current?.state === 'suspended') {
          audioContextRef.current.resume();
        }
      });
    }
    setIsPlaying(!isPlaying);
  }, [isPlaying, tracks.length, currentIndex, loadTrack]);

  const seek = useCallback((time) => {
    if (audioElementRef.current) {
      audioElementRef.current.currentTime = time;
      setCurrentTime(time);
    }
  }, []);

  const changeVolume = useCallback((val) => {
    setVolume(val);
    if (gainNodeRef.current) {
      gainNodeRef.current.gain.value = val;
    }
  }, []);

  const nextTrack = useCallback(() => {
    if (currentIndex < tracks.length - 1) {
      loadTrack(currentIndex + 1);
      audioElementRef.current?.play();
      setIsPlaying(true);
    } else {
      setIsPlaying(false);
    }
  }, [currentIndex, tracks.length, loadTrack]);

  const prevTrack = useCallback(() => {
    if (currentIndex > 0) {
      loadTrack(currentIndex - 1);
      audioElementRef.current?.play();
      setIsPlaying(true);
    }
  }, [currentIndex, loadTrack]);

  const addTrack = useCallback((file) => {
    const url = URL.createObjectURL(file);
    const newTrack = { name: file.name, url, art: null };
    setTracks(prev => [...prev, newTrack]);
  }, []);

  return {
    tracks, currentIndex, isPlaying, currentTime, duration, volume,
    togglePlay, seek, changeVolume, nextTrack, prevTrack, addTrack
  };
}

Expected output: The hook manages all audio state. When you call togglePlay, it creates an AudioContext (resuming it if suspended by browser autoplay policy), connects a MediaElementSource through a gain node, and starts playback.

Step 3: The Player Component

// src/components/Player.js
import React from 'react';

function formatTime(seconds) {
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60).toString().padStart(2, '0');
  return `${m}:${s}`;
}

export default function Player({
  isPlaying, togglePlay, nextTrack, prevTrack,
  currentTime, duration, seek, volume, changeVolume,
  tracks, currentIndex
}) {
  const track = tracks[currentIndex];

  return (
    <div className="player">
      <div className="album-art">
        {track?.art ? (
          <img src={track.art} alt={track.name} />
        ) : (
          <div className="album-placeholder"></div>
        )}
      </div>

      <div className="track-info">
        <h3>{track?.name || 'No track loaded'}</h3>
      </div>

      <input
        type="range"
        className="seek-bar"
        min="0"
        max={duration || 0}
        value={currentTime}
        onChange={(e) => seek(Number(e.target.value))}
      />

      <div className="time-display">
        <span>{formatTime(currentTime)}</span>
        <span>{formatTime(duration)}</span>
      </div>

      <div className="controls">
        <button onClick={prevTrack} disabled={currentIndex <= 0}></button>
        <button className="play-btn" onClick={togglePlay}>
          {isPlaying ? '⏸' : '▶'}
        </button>
        <button onClick={nextTrack} disabled={currentIndex >= tracks.length - 1}></button>
      </div>

      <VolumeControl volume={volume} onChange={changeVolume} />
    </div>
  );
}

Step 4: Volume Control and Seek Bar

// src/components/VolumeControl.js
export default function VolumeControl({ volume, onChange }) {
  return (
    <div className="volume-control">
      <span>🔊</span>
      <input
        type="range"
        min="0"
        max="1"
        step="0.01"
        value={volume}
        onChange={(e) => onChange(Number(e.target.value))}
      />
    </div>
  );
}

Step 5: Playlist Component

// src/components/Playlist.js
import React, { useRef } from 'react';

export default function Playlist({ tracks, currentIndex, addTrack }) {
  const fileInputRef = useRef(null);

  const handleUpload = (e) => {
    const files = Array.from(e.target.files);
    files.forEach(file => addTrack(file));
  };

  return (
    <div className="playlist">
      <div className="playlist-header">
        <h3>Playlist</h3>
        <button onClick={() => fileInputRef.current?.click()}>+ Add</button>
        <input
          ref={fileInputRef}
          type="file"
          accept="audio/*"
          multiple
          hidden
          onChange={handleUpload}
        />
      </div>
      <ul>
        {tracks.map((track, i) => (
          <li key={i} className={i === currentIndex ? 'active' : ''}>
            <span className="track-name">{track.name}</span>
          </li>
        ))}
      </ul>
      {tracks.length === 0 && <p className="empty">Upload audio files to start</p>}
    </div>
  );
}

Step 6: App.js — Putting It All Together

// src/App.js
import React from 'react';
import { useAudioPlayer } from './hooks/useAudioPlayer';
import Player from './components/Player';
import Playlist from './components/Playlist';
import './App.css';

export default function App() {
  const player = useAudioPlayer();

  return (
    <div className="app">
      <h1>Music Player</h1>
      <div className="player-container">
        <Player {...player} />
        <Playlist
          tracks={player.tracks}
          currentIndex={player.currentIndex}
          addTrack={player.addTrack}
        />
      </div>
    </div>
  );
}

Step 7: Styling

/* src/App.css */
.app { max-width: 500px; margin: 40px auto; font-family: system-ui, sans-serif; }
.player-container { background: #1a1a2e; border-radius: 16px; padding: 24px; color: white; }
.album-art { width: 200px; height: 200px; margin: 0 auto 16px; border-radius: 12px; overflow: hidden; }
.album-art img { width: 100%; height: 100%; object-fit: cover; }
.album-placeholder { width: 100%; height: 100%; background: #16213e; display: flex; align-items: center; justify-content: center; font-size: 4em; color: #0f3460; }
.track-info { text-align: center; margin-bottom: 16px; }
.track-info h3 { margin: 0; font-size: 1.1em; }
.seek-bar { width: 100%; margin: 8px 0; }
.time-display { display: flex; justify-content: space-between; font-size: 0.8em; color: #aaa; }
.controls { display: flex; justify-content: center; gap: 16px; margin: 16px 0; }
.controls button { background: none; border: none; color: white; font-size: 1.5em; cursor: pointer; padding: 8px; }
.controls button:disabled { opacity: 0.3; cursor: not-allowed; }
.play-btn { font-size: 2em !important; }
.volume-control { display: flex; align-items: center; gap: 8px; }
.volume-control input { flex: 1; }
.playlist { margin-top: 16px; }
.playlist-header { display: flex; justify-content: space-between; align-items: center; }
.playlist ul { list-style: none; padding: 0; margin: 8px 0; max-height: 200px; overflow-y: auto; }
.playlist li { padding: 8px; border-radius: 8px; margin-bottom: 4px; background: #16213e; }
.playlist li.active { background: #0f3460; border-left: 3px solid #e94560; }
.empty { text-align: center; color: #666; font-style: italic; }

Architecture


sequenceDiagram
    participant User
    participant React as React UI
    participant Hook as useAudioPlayer Hook
    participant WAPI as Web Audio API
    participant Audio as Audio Element

    User->>React: Upload file
    React->>Hook: addTrack(file)
    Hook-->>React: Update tracks state

    User->>React: Click Play
    React->>Hook: togglePlay()
    Hook->>WAPI: Create AudioContext
    Hook->>Audio: Create Audio element
    Hook->>WAPI: createMediaElementSource(audio)
    Hook->>WAPI: connect -> gainNode -> destination
    Hook->>Audio: play()
    Audio-->>WAPI: Audio data flows
    WAPI-->>Hook: timeupdate events
    Hook-->>React: Update currentTime
    React-->>User: Render seek position

    User->>React: Seek / Volume change
    React->>Hook: seek(time) / changeVolume(val)
    Hook->>Audio: Set currentTime
    Hook->>WAPI: Set gainNode.value

Common Errors

1. AudioContext not allowed to start (autoplay policy) Browsers block AudioContext creation until a user gesture. Always resume the context on user interaction: audioContext.resume() inside a click handler. The hook handles this, but if you create AudioContext outside a user event, it stays suspended.

2. Cross-origin audio resource blocked If you load audio from a different domain, the server must send Access-Control-Allow-Origin: * headers. Local files from createObjectURL are fine. Remote URLs without CORS headers throw a media playback error.

3. Memory leak from object URLs Every URL.createObjectURL(file) creates a blob URL that stays in memory until revoked. If users upload many files, memory grows. Call URL.revokeObjectURL(url) when a track is removed or the component unmounts.

4. Audio element not ready when play() is called The Audio element must load metadata before play() succeeds. Listen for loadedmetadata event before setting duration and enabling the play button.

5. “Failed to construct ‘AudioContext’: too many contexts” Browsers limit AudioContext instances (Chrome: ~6). Always reuse a single context. Never create a new one per track. The hook creates one context and reuses it.

Practice Questions

1. Why do we use createMediaElementSource instead of decodeAudioData? createMediaElementSource wraps an <audio> element for streaming playback. decodeAudioData loads the entire file into memory — fine for short samples, wasteful for full songs. The element approach streams, saving memory.

2. What is the purpose of the GainNode? The GainNode controls volume by multiplying the audio signal’s amplitude. Changing gainNode.gain.value adjusts volume instantly without clicks or pops, unlike the audio element’s volume property.

3. Why does the hook return isPlaying as state instead of reading it from the audio element? The audio element’s paused property updates synchronously, but React state triggers re-renders. Using state ensures the UI reflects playback state immediately and correctly during transitions.

4. Challenge: Audio visualizer Add a canvas element and an AnalyserNode between the source and gain node. Use requestAnimationFrame to draw frequency bars from analyser.getByteFrequencyData() in real time.

5. Challenge: Keyboard shortcuts Add keyboard support: Space for play/pause, left/right arrows for seek by 5 seconds, up/down for volume, N for next track, P for previous track. Use a useEffect with keydown listener.

FAQ

Can I play streaming URLs (like radio streams)?
Yes. Pass a streaming URL instead of a local file URL. The Web Audio API’s MediaElementSource works with any audio source the <audio> element supports, including HLS and MP3 streams.
Does the Web Audio API work on mobile browsers?
Yes, with caveats. iOS Safari requires a user gesture to create/resume AudioContext. Some Android browsers have limited audio codec support. Test on target devices. The code above handles the resume pattern.
How do I add audio effects like EQ or reverb?
Connect processing nodes between the source and destination: source.connect(biquadFilter).connect(gainNode).connect(destination). BiquadFilterNode provides low-pass, high-pass, peaking, and shelving filters.

Next Steps

  • Add audio visualizations with the Canvas API and AnalyserNode
  • Explore WebSocket integration for collaborative playlist sync
  • Try the React advanced patterns tutorial for state management optimization
  • Build the Real-Time Dashboard project for another browser API deep dive

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro