Build a Music Player App with React and Web Audio API (Step by Step)
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
- React fundamentals (components, state, hooks)
- JavaScript ES6+ familiarity
- Basic HTML and CSS knowledge
Step 1: Setup the Project
npx create-react-app music-player
cd music-player
npm startClean 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.jsStep 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
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