Kurs vibe coding z AI · Moduł 11: Spotify Clone

Music Player i Playback Control

6 min czytania
W tej lekcji5

Witaj! Czas stworzyć Sea Shanty Sounds - platformę streamingu muzyki pirackich!

To będzie jak Spotify - music player, playlists, albums, artists i personalized recommendations.

Spotify vs YouTube/TikTok

YouTube:

  • Video platform (z muzyką jako dodatek)
  • Long-form content
  • Watch time metrics

TikTok:

  • Short videos z sounds
  • Viral audio clips
  • User-generated content

Spotify:

  • Pure audio streaming platform
  • Full songs i albums
  • Playlists i 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 ? 'Polubione' : 'Lubię to'}
          </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' : ''}`}
            >
              Losowo
            </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' ? 'Powtórz 1' : 'Powtarzaj'}
            </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">
            Lista
          </button>

          <button
            onClick={toggleMute}
            className="text-gray-400 hover:text-white"
          >
            {isMuted || volume === 0 ? 'Wyciszony' : volume < 50 ? 'Cicho' : 'Głośno'}
          </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">
            Pełny ekran
          </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>
  );
}

Podsumowanie

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

W następnym ćwiczeniu dodamy playlists i library!

Do zobaczenia!

Kod do tej lekcji: 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: "Hymn piratów",
  artist: 'Kapitan Redbeard',
  album: 'Morskie przygody',
  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">
        {/* Informacje o utworze */}
        <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 ? 'Polubione' : 'Lubię to'}
          </button>
        </div>

        {/* Sterowanie odtwarzaniem */}
        <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' : ''}`}
            >
              Losowo
            </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' ? 'Powtórz 1' : 'Powtarzaj'}
            </button>
          </div>

          {/* Pasek postępu */}
          <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>

        {/* Głośność */}
        <div className="flex items-center gap-3 w-1/4 justify-end">
          <button className="text-gray-400 hover:text-white">
            Lista
          </button>

          <button onClick={toggleMute} className="text-gray-400 hover:text-white">
            {isMuted || volume === 0 ? 'Wyciszony' : volume < 50 ? 'Cicho' : 'Głośno'}
          </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>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Główna różnica Spotify vs YouTube?

  2. 2. Co zawiera Track w Spotify?

To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż sekcje paska music playera od lewej do prawej:

  • Układanie w poziomie

    Ułóż playback controls w kolejności:

  • Edytor kodu

    Music player component

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować wywołanie odtwarzania:

  • Klikanie w kolejności

    Kliknij elementy, aby zbudować deklarację stanu odtwarzania:

  • Układanie w poziomie

    Ułóż tokeny ustawienia głośności w jednej linii:

  • Układanie w pionie

    Uporządkuj kolejność trybów repeat w funkcji cycleRepeat():

  • Układanie w poziomie

    Ułóż elementy progress bara od lewej do prawej:

  • Klikanie w kolejności

    Kliknij elementy, aby podłączyć nasłuchiwanie postępu:

  • Edytor kodu

    Queue management interface

  • Układanie w pionie

    Uporządkuj ikony głośności od wyciszenia do najgłośniej:

  • Układanie w pionie

    Uporządkuj etapy odtwarzania utworu w playerze:

Przydatne artykuły