Vibe coding with AI course · Module 11: Spotify Clone
Music Player and Playback Control
In this lesson5
Welcome! Time to create Sea Shanty Sounds - a pirate music streaming platform!
This will be like Spotify - music player, playlists, albums, artists, and personalized recommendations.
Spotify vs YouTube/TikTok
YouTube:
- Video platform (with music as an addition)
- Long-form content
- Watch time metrics
TikTok:
- Short videos with sounds
- Viral audio clips
- User-generated content
Spotify:
- Pure audio streaming platform
- Full songs and albums
- Playlists and curated collections
- Artist profiles
- Personalized recommendations (Discover Weekly)
- Offline downloads
- Lyrics sync
Track Structure
interface Track {
id: string;
title: string;
artist: Artist;
album: Album;
// Audio
audioUrl: string;
duration: number; // seconds
previewUrl?: string; // 30s preview
// Metadata
genre: string[];
releaseDate: Date;
explicit: boolean;
// Engagement
plays: number;
likes: number;
inPlaylists: number;
// Lyrics
hasLyrics: boolean;
lyrics?: Lyric[];
// User state
isLiked: boolean;
isInLibrary: boolean;
}
interface Artist {
id: string;
name: string;
avatar: string;
banner?: string;
verified: boolean;
followers: number;
monthlyListeners: number;
}
interface Album {
id: string;
title: string;
artist: Artist;
coverArt: string;
releaseDate: Date;
tracks: Track[];
totalDuration: number;
type: 'album' | 'single' | 'ep';
}
interface Lyric {
timestamp: number; // seconds
text: string;
}Music Player
'use client';
import { useState, useRef, useEffect } from 'react';
interface MusicPlayerProps {
currentTrack: Track | null;
queue: Track[];
onNext: () => void;
onPrevious: () => void;
onShuffle: () => void;
onRepeat: () => void;
}
export default function MusicPlayer({
currentTrack,
queue,
onNext,
onPrevious,
onShuffle,
onRepeat
}: MusicPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(100);
const [isMuted, setIsMuted] = useState(false);
const [shuffleOn, setShuffleOn] = useState(false);
const [repeatMode, setRepeatMode] = useState<'off' | 'all' | 'one'>('off');
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (audioRef.current && currentTrack) {
audioRef.current.src = currentTrack.audioUrl;
if (isPlaying) {
audioRef.current.play();
}
}
}, [currentTrack]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const updateTime = () => setCurrentTime(audio.currentTime);
const handleEnded = () => {
if (repeatMode === 'one') {
audio.currentTime = 0;
audio.play();
} else {
onNext();
}
};
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('ended', handleEnded);
};
}, [repeatMode, onNext]);
const togglePlay = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleSeek = (time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
}
};
const handleVolumeChange = (newVolume: number) => {
if (audioRef.current) {
audioRef.current.volume = newVolume / 100;
setVolume(newVolume);
setIsMuted(newVolume === 0);
}
};
const toggleMute = () => {
if (audioRef.current) {
if (isMuted) {
audioRef.current.volume = volume / 100;
setIsMuted(false);
} else {
audioRef.current.volume = 0;
setIsMuted(true);
}
}
};
const toggleShuffle = () => {
setShuffleOn(!shuffleOn);
onShuffle();
};
const cycleRepeat = () => {
const modes: Array<'off' | 'all' | 'one'> = ['off', 'all', 'one'];
const currentIndex = modes.indexOf(repeatMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setRepeatMode(nextMode);
onRepeat();
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!currentTrack) {
return (
<div className="h-24 bg-gray-900 border-t border-gray-800 flex items-center justify-center text-gray-500">
No track playing
</div>
);
}
return (
<>
<audio ref={audioRef} />
<div className="h-24 bg-gray-900 border-t border-gray-800 px-4 flex items-center gap-4">
{/* Track Info */}
<div className="flex items-center gap-3 w-1/4">
<img
src={currentTrack.album.coverArt}
alt={currentTrack.title}
className="w-14 h-14 rounded"
/>
<div className="flex-1 min-w-0">
<div className="font-semibold text-white text-sm truncate">
{currentTrack.title}
</div>
<div className="text-xs text-gray-400 truncate">
{currentTrack.artist.name}
</div>
</div>
<button className="text-gray-400 hover:text-white">
{currentTrack.isLiked ? 'Liked' : 'Like'}
</button>
</div>
{/* Playback Controls */}
<div className="flex-1 flex flex-col items-center gap-2">
{/* Buttons */}
<div className="flex items-center gap-4">
<button
onClick={toggleShuffle}
className={`text-gray-400 hover:text-white ${shuffleOn ? 'text-green-500' : ''}`}
>
Shuffle
</button>
<button
onClick={onPrevious}
className="text-gray-400 hover:text-white"
>
⏮
</button>
<button
onClick={togglePlay}
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition"
>
<span className="text-black text-xl">
{isPlaying ? '⏸' : '▶'}
</span>
</button>
<button
onClick={onNext}
className="text-gray-400 hover:text-white"
>
⏭
</button>
<button
onClick={cycleRepeat}
className={`text-gray-400 hover:text-white ${
repeatMode !== 'off' ? 'text-green-500' : ''
}`}
>
{repeatMode === 'one' ? 'Repeat 1' : 'Repeat'}
</button>
</div>
{/* Progress Bar */}
<div className="w-full flex items-center gap-2">
<span className="text-xs text-gray-400 w-10 text-right">
{formatTime(currentTime)}
</span>
<div className="flex-1 group">
<input
type="range"
min={0}
max={currentTrack.duration}
value={currentTime}
onChange={(e) => handleSeek(parseFloat(e.target.value))}
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer group-hover:h-1.5 transition-all"
/>
</div>
<span className="text-xs text-gray-400 w-10">
{formatTime(currentTrack.duration)}
</span>
</div>
</div>
{/* Volume & Queue */}
<div className="flex items-center gap-3 w-1/4 justify-end">
<button className="text-gray-400 hover:text-white">
List
</button>
<button
onClick={toggleMute}
className="text-gray-400 hover:text-white"
>
{isMuted || volume === 0 ? 'Muted' : volume < 50 ? 'Low' : 'Loud'}
</button>
<input
type="range"
min={0}
max={100}
value={isMuted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseInt(e.target.value))}
className="w-24 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
/>
<button className="text-gray-400 hover:text-white">
Fullscreen
</button>
</div>
</div>
</>
);
}Queue Management
'use client';
interface QueueProps {
queue: Track[];
currentIndex: number;
onTrackClick: (index: number) => void;
onRemove: (index: number) => void;
onClear: () => void;
}
export default function Queue({
queue,
currentIndex,
onTrackClick,
onRemove,
onClear
}: QueueProps) {
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col h-screen">
{/* Header */}
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-white">Queue</h3>
<button
onClick={onClear}
className="text-xs text-gray-400 hover:text-white"
>
Clear
</button>
</div>
<p className="text-xs text-gray-400">
{queue.length} tracks
</p>
</div>
{/* Now Playing */}
{queue[currentIndex] && (
<div className="p-4 bg-gray-800 border-b border-gray-700">
<p className="text-xs text-gray-400 mb-2">Now Playing</p>
<div className="flex items-center gap-3">
<img
src={queue[currentIndex].album.coverArt}
alt={queue[currentIndex].title}
className="w-12 h-12 rounded"
/>
<div className="flex-1 min-w-0">
<div className="font-semibold text-white text-sm truncate">
{queue[currentIndex].title}
</div>
<div className="text-xs text-gray-400 truncate">
{queue[currentIndex].artist.name}
</div>
</div>
</div>
</div>
)}
{/* Next Up */}
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<p className="text-xs text-gray-400 mb-3">Next Up</p>
<div className="space-y-2">
{queue.slice(currentIndex + 1).map((track, idx) => {
const actualIndex = currentIndex + 1 + idx;
return (
<div
key={track.id}
onClick={() => onTrackClick(actualIndex)}
className="flex items-center gap-3 p-2 hover:bg-gray-800 rounded cursor-pointer group"
>
<img
src={track.album.coverArt}
alt={track.title}
className="w-10 h-10 rounded"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-white truncate">
{track.title}
</div>
<div className="text-xs text-gray-400 truncate">
{track.artist.name}
</div>
</div>
<span className="text-xs text-gray-400">
{formatDuration(track.duration)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onRemove(actualIndex);
}}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
>
×
</button>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}Summary
Music player - play/pause, next/previous, seek Playback controls - shuffle, repeat (off/all/one) Progress bar - seek to any position Volume control - slider + mute button Track info - title, artist, album art Queue management - view queue, skip to track, remove Time formatting - MM:SS display Auto-advance - next track when current ends
In the next exercise we'll add playlists and library!
See you soon!
Code for this lesson: App.tsx
'use client';
import { useState, useRef, useEffect } from 'react';
interface Track {
id: string;
title: string;
artist: string;
album: string;
coverArt: string;
audioUrl: string;
duration: number;
isLiked: boolean;
}
const mockTrack: Track = {
id: '1',
title: "The Pirate's Anthem",
artist: 'Captain Redbeard',
album: 'Sea Adventures',
coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=300&h=300&fit=crop',
audioUrl: '',
duration: 240,
isLiked: true
};
export default function MusicPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(70);
const [isMuted, setIsMuted] = useState(false);
const [shuffleOn, setShuffleOn] = useState(false);
const [repeatMode, setRepeatMode] = useState<'off' | 'all' | 'one'>('off');
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
const interval = setInterval(() => {
if (isPlaying && currentTime < mockTrack.duration) {
setCurrentTime(prev => prev + 1);
}
}, 1000);
return () => clearInterval(interval);
}, [isPlaying, currentTime]);
const togglePlay = () => setIsPlaying(!isPlaying);
const handleSeek = (time: number) => setCurrentTime(time);
const toggleMute = () => setIsMuted(!isMuted);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="min-h-screen bg-black text-white flex items-end">
<div className="w-full h-24 bg-gray-900 border-t border-gray-800 px-4 flex items-center gap-4">
{/* Track Info */}
<div className="flex items-center gap-3 w-1/4">
<img
src={mockTrack.coverArt}
alt={mockTrack.title}
className="w-14 h-14 rounded"
/>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{mockTrack.title}
</div>
<div className="text-xs text-gray-400 truncate">
{mockTrack.artist}
</div>
</div>
<button className="text-xl">
{mockTrack.isLiked ? 'Liked' : 'Like'}
</button>
</div>
{/* Playback Controls */}
<div className="flex-1 flex flex-col items-center gap-2">
<div className="flex items-center gap-4">
<button
onClick={() => setShuffleOn(!shuffleOn)}
className={`text-gray-400 hover:text-white ${shuffleOn ? 'text-green-500' : ''}`}
>
Shuffle
</button>
<button className="text-gray-400 hover:text-white text-xl">
⏮
</button>
<button
onClick={togglePlay}
className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition"
>
<span className="text-black text-xl">
{isPlaying ? '⏸' : '▶'}
</span>
</button>
<button className="text-gray-400 hover:text-white text-xl">
⏭
</button>
<button
onClick={() => {
const modes: Array<'off' | 'all' | 'one'> = ['off', 'all', 'one'];
const idx = modes.indexOf(repeatMode);
setRepeatMode(modes[(idx + 1) % modes.length]);
}}
className={`text-gray-400 hover:text-white ${repeatMode !== 'off' ? 'text-green-500' : ''}`}
>
{repeatMode === 'one' ? 'Repeat 1' : 'Repeat'}
</button>
</div>
{/* Progress Bar */}
<div className="w-full flex items-center gap-2">
<span className="text-xs text-gray-400 w-10 text-right">
{formatTime(currentTime)}
</span>
<div className="flex-1">
<input
type="range"
min={0}
max={mockTrack.duration}
value={currentTime}
onChange={(e) => handleSeek(parseFloat(e.target.value))}
className="w-full h-1 bg-gray-600 rounded-lg"
/>
</div>
<span className="text-xs text-gray-400 w-10">
{formatTime(mockTrack.duration)}
</span>
</div>
</div>
{/* Volume */}
<div className="flex items-center gap-3 w-1/4 justify-end">
<button className="text-gray-400 hover:text-white">
List
</button>
<button onClick={toggleMute} className="text-gray-400 hover:text-white">
{isMuted || volume === 0 ? 'Muted' : volume < 50 ? 'Low' : 'Loud'}
</button>
<input
type="range"
min={0}
max={100}
value={isMuted ? 0 : volume}
onChange={(e) => setVolume(parseInt(e.target.value))}
className="w-24 h-1 bg-gray-600 rounded-lg"
/>
</div>
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Main difference between Spotify and YouTube?
2. What does a Track in Spotify contain?
These are 2 of 7 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the music player bar sections from left to right:
- Horizontal ordering
Arrange the playback controls in order:
- Code editor
Music player component
- Click in order
Click the elements to build the playback call:
- Click in order
Click the elements to build the playback state declaration:
- Horizontal ordering
Arrange the volume-setting tokens on one line:
- Vertical ordering
Order the sequence of repeat modes in the cycleRepeat() function:
- Horizontal ordering
Arrange the progress bar elements from left to right:
- Click in order
Click the elements to attach the progress listener:
- Code editor
Queue management interface
- Vertical ordering
Order the volume icons from muted to loudest:
- Vertical ordering
Order the stages of playing a track in the player: