Cours Vibe coding avec l'IA · Module 11: Clone de Spotify

Lecteur de musique et contrôle de la lecture

6 min de lecture
Dans cette leçon5

Bienvenue ! Il est temps de créer Sea Shanty Sounds - une plateforme de streaming musical pirate !

Ce sera comme Spotify - lecteur de musique, playlists, albums, artistes et recommandations personnalisées.

Spotify vs YouTube/TikTok

YouTube :

  • Plateforme vidéo (avec la musique en complément)
  • Contenu long format
  • Métriques de temps de visionnage

TikTok :

  • Vidéos courtes avec des sons
  • Extraits audio viraux
  • Contenu généré par les utilisateurs

Spotify :

  • Plateforme de streaming purement audio
  • Chansons et albums complets
  • Playlists et collections organisées
  • Profils d'artistes
  • Recommandations personnalisées (Discover Weekly)
  • Téléchargements hors ligne
  • Synchronisation des paroles

Structure d'un morceau (Track)

1interface Track {
2  id: string;
3  title: string;
4  artist: Artist;
5  album: Album;
6
7  // Audio
8  audioUrl: string;
9  duration: number; // secondes
10  previewUrl?: string; // aperçu de 30 s
11
12  // Métadonnées
13  genre: string[];
14  releaseDate: Date;
15  explicit: boolean;
16
17  // Engagement
18  plays: number;
19  likes: number;
20  inPlaylists: number;
21
22  // Paroles
23  hasLyrics: boolean;
24  lyrics?: Lyric[];
25
26  // État utilisateur
27  isLiked: boolean;
28  isInLibrary: boolean;
29}
30
31interface Artist {
32  id: string;
33  name: string;
34  avatar: string;
35  banner?: string;
36  verified: boolean;
37  followers: number;
38  monthlyListeners: number;
39}
40
41interface Album {
42  id: string;
43  title: string;
44  artist: Artist;
45  coverArt: string;
46  releaseDate: Date;
47  tracks: Track[];
48  totalDuration: number;
49  type: 'album' | 'single' | 'ep';
50}
51
52interface Lyric {
53  timestamp: number; // secondes
54  text: string;
55}

Lecteur de musique

1'use client';
2
3import { useState, useRef, useEffect } from 'react';
4
5interface MusicPlayerProps {
6  currentTrack: Track | null;
7  queue: Track[];
8  onNext: () => void;
9  onPrevious: () => void;
10  onShuffle: () => void;
11  onRepeat: () => void;
12}
13
14export default function MusicPlayer({
15  currentTrack,
16  queue,
17  onNext,
18  onPrevious,
19  onShuffle,
20  onRepeat
21}: MusicPlayerProps) {
22  const [isPlaying, setIsPlaying] = useState(false);
23  const [currentTime, setCurrentTime] = useState(0);
24  const [volume, setVolume] = useState(100);
25  const [isMuted, setIsMuted] = useState(false);
26  const [shuffleOn, setShuffleOn] = useState(false);
27  const [repeatMode, setRepeatMode] = useState<'off' | 'all' | 'one'>('off');
28
29  const audioRef = useRef<HTMLAudioElement>(null);
30
31  useEffect(() => {
32    if (audioRef.current && currentTrack) {
33      audioRef.current.src = currentTrack.audioUrl;
34      if (isPlaying) {
35        audioRef.current.play();
36      }
37    }
38  }, [currentTrack]);
39
40  useEffect(() => {
41    const audio = audioRef.current;
42    if (!audio) return;
43
44    const updateTime = () => setCurrentTime(audio.currentTime);
45    const handleEnded = () => {
46      if (repeatMode === 'one') {
47        audio.currentTime = 0;
48        audio.play();
49      } else {
50        onNext();
51      }
52    };
53
54    audio.addEventListener('timeupdate', updateTime);
55    audio.addEventListener('ended', handleEnded);
56
57    return () => {
58      audio.removeEventListener('timeupdate', updateTime);
59      audio.removeEventListener('ended', handleEnded);
60    };
61  }, [repeatMode, onNext]);
62
63  const togglePlay = () => {
64    if (audioRef.current) {
65      if (isPlaying) {
66        audioRef.current.pause();
67      } else {
68        audioRef.current.play();
69      }
70      setIsPlaying(!isPlaying);
71    }
72  };
73
74  const handleSeek = (time: number) => {
75    if (audioRef.current) {
76      audioRef.current.currentTime = time;
77      setCurrentTime(time);
78    }
79  };
80
81  const handleVolumeChange = (newVolume: number) => {
82    if (audioRef.current) {
83      audioRef.current.volume = newVolume / 100;
84      setVolume(newVolume);
85      setIsMuted(newVolume === 0);
86    }
87  };
88
89  const toggleMute = () => {
90    if (audioRef.current) {
91      if (isMuted) {
92        audioRef.current.volume = volume / 100;
93        setIsMuted(false);
94      } else {
95        audioRef.current.volume = 0;
96        setIsMuted(true);
97      }
98    }
99  };
100
101  const toggleShuffle = () => {
102    setShuffleOn(!shuffleOn);
103    onShuffle();
104  };
105
106  const cycleRepeat = () => {
107    const modes: Array<'off' | 'all' | 'one'> = ['off', 'all', 'one'];
108    const currentIndex = modes.indexOf(repeatMode);
109    const nextMode = modes[(currentIndex + 1) % modes.length];
110    setRepeatMode(nextMode);
111    onRepeat();
112  };
113
114  const formatTime = (seconds: number) => {
115    const mins = Math.floor(seconds / 60);
116    const secs = Math.floor(seconds % 60);
117    return `${mins}:${secs.toString().padStart(2, '0')}`;
118  };
119
120  if (!currentTrack) {
121    return (
122      <div className="h-24 bg-gray-900 border-t border-gray-800 flex items-center justify-center text-gray-500">
123        Aucun morceau en lecture
124      </div>
125    );
126  }
127
128  return (
129    <>
130      <audio ref={audioRef} />
131
132      <div className="h-24 bg-gray-900 border-t border-gray-800 px-4 flex items-center gap-4">
133        {/* Infos du morceau */}
134        <div className="flex items-center gap-3 w-1/4">
135          <img
136            src={currentTrack.album.coverArt}
137            alt={currentTrack.title}
138            className="w-14 h-14 rounded"
139          />
140          <div className="flex-1 min-w-0">
141            <div className="font-semibold text-white text-sm truncate">
142              {currentTrack.title}
143            </div>
144            <div className="text-xs text-gray-400 truncate">
145              {currentTrack.artist.name}
146            </div>
147          </div>
148          <button className="text-gray-400 hover:text-white">
149            {currentTrack.isLiked ? 'Aimé' : 'J’aime'}
150          </button>
151        </div>
152
153        {/* Contrôles de lecture */}
154        <div className="flex-1 flex flex-col items-center gap-2">
155          {/* Boutons */}
156          <div className="flex items-center gap-4">
157            <button
158              onClick={toggleShuffle}
159              className={`text-gray-400 hover:text-white ${shuffleOn ? 'text-green-500' : ''}`}
160            >
161              Aléatoire
162            </button>
163
164            <button
165              onClick={onPrevious}
166              className="text-gray-400 hover:text-white"
167            >
168              ⏮
169            </button>
170
171            <button
172              onClick={togglePlay}
173              className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition"
174            >
175              <span className="text-black text-xl">
176                {isPlaying ? '⏸' : '▶'}
177              </span>
178            </button>
179
180            <button
181              onClick={onNext}
182              className="text-gray-400 hover:text-white"
183            >
184              ⏭
185            </button>
186
187            <button
188              onClick={cycleRepeat}
189              className={`text-gray-400 hover:text-white ${
190                repeatMode !== 'off' ? 'text-green-500' : ''
191              }`}
192            >
193              {repeatMode === 'one' ? 'Répéter 1' : 'Répéter'}
194            </button>
195          </div>
196
197          {/* Barre de progression */}
198          <div className="w-full flex items-center gap-2">
199            <span className="text-xs text-gray-400 w-10 text-right">
200              {formatTime(currentTime)}
201            </span>
202            <div className="flex-1 group">
203              <input
204                type="range"
205                min={0}
206                max={currentTrack.duration}
207                value={currentTime}
208                onChange={(e) => handleSeek(parseFloat(e.target.value))}
209                className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer group-hover:h-1.5 transition-all"
210              />
211            </div>
212            <span className="text-xs text-gray-400 w-10">
213              {formatTime(currentTrack.duration)}
214            </span>
215          </div>
216        </div>
217
218        {/* Volume & file d'attente */}
219        <div className="flex items-center gap-3 w-1/4 justify-end">
220          <button className="text-gray-400 hover:text-white">
221            Liste
222          </button>
223
224          <button
225            onClick={toggleMute}
226            className="text-gray-400 hover:text-white"
227          >
228            {isMuted || volume === 0 ? 'Muet' : volume < 50 ? 'Bas' : 'Fort'}
229          </button>
230
231          <input
232            type="range"
233            min={0}
234            max={100}
235            value={isMuted ? 0 : volume}
236            onChange={(e) => handleVolumeChange(parseInt(e.target.value))}
237            className="w-24 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
238          />
239
240          <button className="text-gray-400 hover:text-white">
241            Plein écran
242          </button>
243        </div>
244      </div>
245    </>
246  );
247}

Gestion de la file d'attente

1'use client';
2
3interface QueueProps {
4  queue: Track[];
5  currentIndex: number;
6  onTrackClick: (index: number) => void;
7  onRemove: (index: number) => void;
8  onClear: () => void;
9}
10
11export default function Queue({
12  queue,
13  currentIndex,
14  onTrackClick,
15  onRemove,
16  onClear
17}: QueueProps) {
18  const formatDuration = (seconds: number) => {
19    const mins = Math.floor(seconds / 60);
20    const secs = Math.floor(seconds % 60);
21    return `${mins}:${secs.toString().padStart(2, '0')}`;
22  };
23
24  return (
25    <div className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col h-screen">
26      {/* En-tête */}
27      <div className="p-4 border-b border-gray-800">
28        <div className="flex items-center justify-between mb-2">
29          <h3 className="font-bold text-white">File d'attente</h3>
30          <button
31            onClick={onClear}
32            className="text-xs text-gray-400 hover:text-white"
33          >
34            Vider
35          </button>
36        </div>
37        <p className="text-xs text-gray-400">
38          {queue.length} morceaux
39        </p>
40      </div>
41
42      {/* En cours de lecture */}
43      {queue[currentIndex] && (
44        <div className="p-4 bg-gray-800 border-b border-gray-700">
45          <p className="text-xs text-gray-400 mb-2">En cours de lecture</p>
46          <div className="flex items-center gap-3">
47            <img
48              src={queue[currentIndex].album.coverArt}
49              alt={queue[currentIndex].title}
50              className="w-12 h-12 rounded"
51            />
52            <div className="flex-1 min-w-0">
53              <div className="font-semibold text-white text-sm truncate">
54                {queue[currentIndex].title}
55              </div>
56              <div className="text-xs text-gray-400 truncate">
57                {queue[currentIndex].artist.name}
58              </div>
59            </div>
60          </div>
61        </div>
62      )}
63
64      {/* À suivre */}
65      <div className="flex-1 overflow-y-auto">
66        <div className="p-4">
67          <p className="text-xs text-gray-400 mb-3">À suivre</p>
68          <div className="space-y-2">
69            {queue.slice(currentIndex + 1).map((track, idx) => {
70              const actualIndex = currentIndex + 1 + idx;
71              return (
72                <div
73                  key={track.id}
74                  onClick={() => onTrackClick(actualIndex)}
75                  className="flex items-center gap-3 p-2 hover:bg-gray-800 rounded cursor-pointer group"
76                >
77                  <img
78                    src={track.album.coverArt}
79                    alt={track.title}
80                    className="w-10 h-10 rounded"
81                  />
82                  <div className="flex-1 min-w-0">
83                    <div className="text-sm text-white truncate">
84                      {track.title}
85                    </div>
86                    <div className="text-xs text-gray-400 truncate">
87                      {track.artist.name}
88                    </div>
89                  </div>
90                  <span className="text-xs text-gray-400">
91                    {formatDuration(track.duration)}
92                  </span>
93                  <button
94                    onClick={(e) => {
95                      e.stopPropagation();
96                      onRemove(actualIndex);
97                    }}
98                    className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
99                  >
100                    ×
101                  </button>
102                </div>
103              );
104            })}
105          </div>
106        </div>
107      </div>
108    </div>
109  );
110}

Récapitulatif

Lecteur de musique - lecture/pause, suivant/précédent, avance Contrôles de lecture - aléatoire, répétition (off/all/one) Barre de progression - avancer à n'importe quelle position Contrôle du volume - curseur + bouton muet Infos du morceau - titre, artiste, pochette de l'album Gestion de la file d'attente - voir la file, passer à un morceau, retirer Formatage du temps - affichage MM:SS Avance automatique - morceau suivant quand le courant se termine

Dans le prochain exercice, nous ajouterons les playlists et la bibliothèque !

À très bientôt !

Code de cette leçon : App.tsx
1'use client';
2
3import { useState, useRef, useEffect } from 'react';
4
5interface Track {
6  id: string;
7  title: string;
8  artist: string;
9  album: string;
10  coverArt: string;
11  audioUrl: string;
12  duration: number;
13  isLiked: boolean;
14}
15
16const mockTrack: Track = {
17  id: '1',
18  title: "The Pirate's Anthem",
19  artist: 'Captain Redbeard',
20  album: 'Sea Adventures',
21  coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=300&h=300&fit=crop',
22  audioUrl: '',
23  duration: 240,
24  isLiked: true
25};
26
27export default function MusicPlayer() {
28  const [isPlaying, setIsPlaying] = useState(false);
29  const [currentTime, setCurrentTime] = useState(0);
30  const [volume, setVolume] = useState(70);
31  const [isMuted, setIsMuted] = useState(false);
32  const [shuffleOn, setShuffleOn] = useState(false);
33  const [repeatMode, setRepeatMode] = useState<'off' | 'all' | 'one'>('off');
34  const audioRef = useRef<HTMLAudioElement>(null);
35
36  useEffect(() => {
37    const interval = setInterval(() => {
38      if (isPlaying && currentTime < mockTrack.duration) {
39        setCurrentTime(prev => prev + 1);
40      }
41    }, 1000);
42    return () => clearInterval(interval);
43  }, [isPlaying, currentTime]);
44
45  const togglePlay = () => setIsPlaying(!isPlaying);
46
47  const handleSeek = (time: number) => setCurrentTime(time);
48
49  const toggleMute = () => setIsMuted(!isMuted);
50
51  const formatTime = (seconds: number) => {
52    const mins = Math.floor(seconds / 60);
53    const secs = Math.floor(seconds % 60);
54    return `${mins}:${secs.toString().padStart(2, '0')}`;
55  };
56
57  return (
58    <div className="min-h-screen bg-black text-white flex items-end">
59      <div className="w-full h-24 bg-gray-900 border-t border-gray-800 px-4 flex items-center gap-4">
60        {/* Infos du morceau */}
61        <div className="flex items-center gap-3 w-1/4">
62          <img
63            src={mockTrack.coverArt}
64            alt={mockTrack.title}
65            className="w-14 h-14 rounded"
66          />
67          <div className="flex-1 min-w-0">
68            <div className="font-semibold text-sm truncate">
69              {mockTrack.title}
70            </div>
71            <div className="text-xs text-gray-400 truncate">
72              {mockTrack.artist}
73            </div>
74          </div>
75          <button className="text-xl">
76            {mockTrack.isLiked ? 'Aimé' : 'J’aime'}
77          </button>
78        </div>
79
80        {/* Contrôles de lecture */}
81        <div className="flex-1 flex flex-col items-center gap-2">
82          <div className="flex items-center gap-4">
83            <button
84              onClick={() => setShuffleOn(!shuffleOn)}
85              className={`text-gray-400 hover:text-white ${shuffleOn ? 'text-green-500' : ''}`}
86            >
87              Aléatoire
88            </button>
89
90            <button className="text-gray-400 hover:text-white text-xl">
91              ⏮
92            </button>
93
94            <button
95              onClick={togglePlay}
96              className="w-8 h-8 bg-white rounded-full flex items-center justify-center hover:scale-105 transition"
97            >
98              <span className="text-black text-xl">
99                {isPlaying ? '⏸' : '▶'}
100              </span>
101            </button>
102
103            <button className="text-gray-400 hover:text-white text-xl">
104              ⏭
105            </button>
106
107            <button
108              onClick={() => {
109                const modes: Array<'off' | 'all' | 'one'> = ['off', 'all', 'one'];
110                const idx = modes.indexOf(repeatMode);
111                setRepeatMode(modes[(idx + 1) % modes.length]);
112              }}
113              className={`text-gray-400 hover:text-white ${repeatMode !== 'off' ? 'text-green-500' : ''}`}
114            >
115              {repeatMode === 'one' ? 'Répéter 1' : 'Répéter'}
116            </button>
117          </div>
118
119          {/* Barre de progression */}
120          <div className="w-full flex items-center gap-2">
121            <span className="text-xs text-gray-400 w-10 text-right">
122              {formatTime(currentTime)}
123            </span>
124            <div className="flex-1">
125              <input
126                type="range"
127                min={0}
128                max={mockTrack.duration}
129                value={currentTime}
130                onChange={(e) => handleSeek(parseFloat(e.target.value))}
131                className="w-full h-1 bg-gray-600 rounded-lg"
132              />
133            </div>
134            <span className="text-xs text-gray-400 w-10">
135              {formatTime(mockTrack.duration)}
136            </span>
137          </div>
138        </div>
139
140        {/* Volume */}
141        <div className="flex items-center gap-3 w-1/4 justify-end">
142          <button className="text-gray-400 hover:text-white">
143            Liste
144          </button>
145
146          <button onClick={toggleMute} className="text-gray-400 hover:text-white">
147            {isMuted || volume === 0 ? 'Muet' : volume < 50 ? 'Bas' : 'Fort'}
148          </button>
149
150          <input
151            type="range"
152            min={0}
153            max={100}
154            value={isMuted ? 0 : volume}
155            onChange={(e) => setVolume(parseInt(e.target.value))}
156            className="w-24 h-1 bg-gray-600 rounded-lg"
157          />
158        </div>
159      </div>
160    </div>
161  );
162}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Principale différence entre Spotify et YouTube ?

  2. 2. Que contient un Track (morceau) dans Spotify ?

Voici 2 des 7 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne les sections de la barre du lecteur de musique de gauche à droite :

  • Tri horizontal

    Ordonne les contrôles de lecture dans l'ordre :

  • Éditeur de code

    Composant lecteur de musique

  • Clique dans l'ordre

    Clique sur les éléments pour construire l'appel de lecture :

  • Clique dans l'ordre

    Clique sur les éléments pour construire la déclaration de l'état de lecture :

  • Tri horizontal

    Ordonne les éléments de réglage du volume sur une ligne :

  • Tri vertical

    Ordonne la séquence des modes de répétition dans la fonction cycleRepeat() :

  • Tri horizontal

    Ordonne les éléments de la barre de progression de gauche à droite :

  • Clique dans l'ordre

    Clique sur les éléments pour attacher l'écouteur de progression :

  • Éditeur de code

    Interface de gestion de la file d'attente

  • Tri vertical

    Ordonne les icônes de volume du muet au plus fort :

  • Tri vertical

    Ordonne les étapes de la lecture d'un morceau dans le lecteur :

Articles utiles