Vibe coding with AI course · Module 11: Spotify Clone

Music Player and Playback Control

6 min read
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. 1. Main difference between Spotify and YouTube?

  2. 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:

Useful articles