Kurs vibe coding z AI · Moduł 11: Spotify Clone
Music Player i Playback Control
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. Główna różnica Spotify vs YouTube?
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: