Cours Vibe coding avec l'IA · Module 11: Clone de Spotify
Recherche, découverte et recommandations
Dans cette leçon4
La puissance de Spotify réside dans la découverte - une recherche puissante, des playlists personnalisées (Discover Weekly, Release Radar) et des recommandations.
Système de recherche
1interface SearchResults {
2 tracks: Track[];
3 albums: Album[];
4 artists: Artist[];
5 playlists: Playlist[];
6}
7
8interface SearchFilters {
9 type?: 'track' | 'album' | 'artist' | 'playlist';
10 genre?: string[];
11 year?: { min?: number; max?: number };
12 explicit?: boolean;
13}
14
15// API de recherche
16async function search(
17 query: string,
18 filters?: SearchFilters
19): Promise<SearchResults> {
20 // Implémentation...
21}Page de recherche
1'use client';
2
3import { useState, useEffect } from 'react';
4
5export default function SearchPage() {
6 const [query, setQuery] = useState('');
7 const [activeTab, setActiveTab] = useState<'all' | 'tracks' | 'albums' | 'artists' | 'playlists'>('all');
8 const [results, setResults] = useState<SearchResults | null>(null);
9
10 useEffect(() => {
11 if (query.length > 0) {
12 // Recherche avec debounce
13 const timeout = setTimeout(() => {
14 performSearch(query);
15 }, 300);
16
17 return () => clearTimeout(timeout);
18 }
19 }, [query]);
20
21 const performSearch = async (searchQuery: string) => {
22 // Résultats de recherche fictifs
23 setResults({
24 tracks: mockTracks.filter(t =>
25 t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
26 t.artist.name.toLowerCase().includes(searchQuery.toLowerCase())
27 ),
28 albums: mockAlbums.filter(a =>
29 a.title.toLowerCase().includes(searchQuery.toLowerCase())
30 ),
31 artists: mockArtists.filter(a =>
32 a.name.toLowerCase().includes(searchQuery.toLowerCase())
33 ),
34 playlists: mockPlaylists.filter(p =>
35 p.name.toLowerCase().includes(searchQuery.toLowerCase())
36 )
37 });
38 };
39
40 return (
41 <div className="min-h-screen bg-black text-white p-8">
42 {/* Champ de recherche */}
43 <div className="mb-6">
44 <div className="relative max-w-md">
45 <input
46 type="text"
47 value={query}
48 onChange={(e) => setQuery(e.target.value)}
49 placeholder="Que veux-tu écouter ?"
50 className="w-full bg-gray-800 text-white px-12 py-3 rounded-full outline-none focus:ring-2 focus:ring-white"
51 autoFocus
52 />
53 <span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-xl">
54 ⌕
55 </span>
56 </div>
57 </div>
58
59 {!query ? (
60 // Tout parcourir / Recherches récentes
61 <div>
62 <h2 className="text-2xl font-bold mb-4">Tout parcourir</h2>
63 <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
64 {['Sea Shanties', 'Battle Anthems', 'Treasure Hunting', 'Calm Seas', 'Epic Adventures', 'Ship Repairs'].map(genre => (
65 <button
66 key={genre}
67 className="aspect-square bg-gradient-to-br from-purple-600 to-pink-600 rounded-lg p-4 font-bold text-lg hover:scale-105 transition"
68 >
69 {genre}
70 </button>
71 ))}
72 </div>
73 </div>
74 ) : results && (
75 <>
76 {/* Onglets */}
77 <div className="flex gap-4 mb-6 border-b border-gray-800">
78 {[
79 { key: 'all', label: 'Tout' },
80 { key: 'tracks', label: 'Titres' },
81 { key: 'albums', label: 'Albums' },
82 { key: 'artists', label: 'Artistes' },
83 { key: 'playlists', label: 'Playlists' }
84 ].map(tab => (
85 <button
86 key={tab.key}
87 onClick={() => setActiveTab(tab.key as any)}
88 className={`pb-2 px-4 font-semibold ${
89 activeTab === tab.key
90 ? 'border-b-2 border-green-500'
91 : 'text-gray-400'
92 }`}
93 >
94 {tab.label}
95 </button>
96 ))}
97 </div>
98
99 {/* Résultats */}
100 <div className="space-y-6">
101 {(activeTab === 'all' || activeTab === 'tracks') && results.tracks.length > 0 && (
102 <div>
103 <h3 className="text-xl font-bold mb-4">Titres</h3>
104 <div className="space-y-2">
105 {results.tracks.slice(0, activeTab === 'all' ? 4 : undefined).map(track => (
106 <div
107 key={track.id}
108 className="flex items-center gap-4 p-2 rounded hover:bg-gray-800 cursor-pointer"
109 >
110 <img
111 src={track.album.coverArt}
112 alt={track.title}
113 className="w-12 h-12 rounded"
114 />
115 <div className="flex-1">
116 <div className="font-semibold">{track.title}</div>
117 <div className="text-sm text-gray-400">
118 {track.artist.name}
119 </div>
120 </div>
121 <span className="text-sm text-gray-400">
122 {Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')}
123 </span>
124 </div>
125 ))}
126 </div>
127 </div>
128 )}
129
130 {(activeTab === 'all' || activeTab === 'artists') && results.artists.length > 0 && (
131 <div>
132 <h3 className="text-xl font-bold mb-4">Artistes</h3>
133 <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
134 {results.artists.slice(0, activeTab === 'all' ? 4 : undefined).map(artist => (
135 <div
136 key={artist.id}
137 className="bg-gray-900 p-4 rounded hover:bg-gray-800 cursor-pointer"
138 >
139 <img
140 src={artist.avatar}
141 alt={artist.name}
142 className="w-full aspect-square rounded-full mb-4"
143 />
144 <div className="font-semibold">{artist.name}</div>
145 <div className="text-sm text-gray-400">Artiste</div>
146 </div>
147 ))}
148 </div>
149 </div>
150 )}
151 </div>
152 </>
153 )}
154 </div>
155 );
156}Système de recommandations
1interface RecommendationEngine {
2 // Basé sur l'historique d'écoute
3 discoverWeekly: (userId: string) => Playlist;
4
5 // Nouvelles sorties des artistes suivis
6 releaseRadar: (userId: string) => Playlist;
7
8 // Basé sur une graine (seed) spécifique
9 radioStation: (seed: Track | Artist | Genre) => Track[];
10
11 // Similaire à un morceau
12 similarTracks: (trackId: string, limit?: number) => Track[];
13}
14
15// Discover Weekly - playlist personnalisée basée sur l'historique d'écoute
16function generateDiscoverWeekly(
17 listeningHistory: Track[],
18 likedTracks: Track[],
19 followedArtists: Artist[]
20): Playlist {
21 // Algorithme :
22 // 1. Analyser les goûts de l'utilisateur (genres, artistes, caractéristiques audio)
23 // 2. Trouver des morceaux similaires que l'utilisateur n'a pas encore écoutés
24 // 3. Pondérer selon la popularité et la récence
25 // 4. Limiter à 30 morceaux
26
27 return {
28 id: 'discover-weekly',
29 name: 'Discover Weekly',
30 description: 'Ta mixtape hebdomadaire de musique fraîche',
31 coverImage: '/discover-weekly.jpg',
32 owner: { id: 'spotify', username: 'Spotify', avatar: '/spotify.png' },
33 tracks: [...], // 30 morceaux recommandés
34 followers: 0,
35 isPublic: false,
36 isCollaborative: false,
37 totalDuration: 0,
38 createdAt: new Date(),
39 updatedAt: new Date()
40 };
41}Récapitulatif
Système de recherche - morceaux, albums, artistes, playlists Recherche avec debounce - délai de 300 ms pour les performances Onglets de recherche - filtrer les résultats par type Catégories à parcourir - cartes de genres Recherches récentes - suivi de l'historique Discover Weekly - recommandations personnalisées Release Radar - nouvelle musique des artistes suivis Radio station - lecture infinie basée sur une graine Morceaux similaires - algorithme de recommandation
Tu disposes maintenant d'un clone complet de Spotify avec lecteur, playlists, recherche et recommandations !
À très bientôt !
Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5interface Track {
6 id: string;
7 title: string;
8 artist: string;
9 album: string;
10 coverArt: string;
11 duration: number;
12 isLiked: boolean;
13}
14
15const mockTracks: Track[] = [
16 {
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=200&h=200&fit=crop',
22 duration: 240,
23 isLiked: true
24 },
25 {
26 id: '2',
27 title: 'Sailing the Seven Seas',
28 artist: 'Crew Council',
29 album: 'Ocean Voyage',
30 coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
31 duration: 195,
32 isLiked: false
33 },
34 {
35 id: '3',
36 title: 'Treasure Hunt',
37 artist: 'Blackbeard Band',
38 album: 'Gold & Glory',
39 coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
40 duration: 210,
41 isLiked: true
42 }
43];
44
45const mockPlaylist = {
46 name: 'Pirate Favorites',
47 description: 'Best sea shanties and adventure songs',
48 coverImage: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop',
49 owner: {
50 username: 'Captain Redbeard',
51 avatar: 'https://i.pravatar.cc/150?img=12'
52 },
53 followers: 1247,
54 tracks: mockTracks
55};
56
57export default function PlaylistPage() {
58 const [tracks, setTracks] = useState(mockTracks);
59
60 const formatDuration = (seconds: number) => {
61 const mins = Math.floor(seconds / 60);
62 const secs = Math.floor(seconds % 60);
63 return `${mins}:${secs.toString().padStart(2, '0')}`;
64 };
65
66 const totalDuration = tracks.reduce((acc, t) => acc + t.duration, 0);
67 const totalMinutes = Math.floor(totalDuration / 60);
68
69 return (
70 <div className="min-h-screen bg-gradient-to-b from-blue-900 to-black text-white">
71 {/* En-tête */}
72 <div className="bg-gradient-to-b from-blue-900 to-transparent p-8">
73 <div className="flex items-end gap-6">
74 <img
75 src={mockPlaylist.coverImage}
76 alt={mockPlaylist.name}
77 className="w-60 h-60 rounded shadow-2xl"
78 />
79
80 <div className="flex-1">
81 <p className="text-sm font-semibold uppercase">Playlist</p>
82 <h1 className="text-5xl font-bold my-4">{mockPlaylist.name}</h1>
83 <p className="text-gray-300 mb-4">{mockPlaylist.description}</p>
84
85 <div className="flex items-center gap-2 text-sm">
86 <img
87 src={mockPlaylist.owner.avatar}
88 alt={mockPlaylist.owner.username}
89 className="w-6 h-6 rounded-full"
90 />
91 <span className="font-semibold">{mockPlaylist.owner.username}</span>
92 <span>•</span>
93 <span>{mockPlaylist.followers.toLocaleString()} abonnés</span>
94 <span>•</span>
95 <span>{tracks.length} titres, {totalMinutes} min</span>
96 </div>
97 </div>
98 </div>
99 </div>
100
101 {/* Actions */}
102 <div className="px-8 py-6 flex items-center gap-4">
103 <button className="w-14 h-14 bg-green-500 rounded-full flex items-center justify-center hover:scale-105 transition">
104 <span className="text-black text-2xl ml-1">▶</span>
105 </button>
106 <button className="text-sm font-semibold text-gray-400 hover:text-white">J’aime</button>
107 <button className="text-3xl text-gray-400 hover:text-white">⋯</button>
108 </div>
109
110 {/* Liste des morceaux */}
111 <div className="px-8 pb-8">
112 <div className="grid grid-cols-[16px_6fr_4fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-gray-700 text-sm text-gray-400 mb-2">
113 <div>#</div>
114 <div>Titre</div>
115 <div>Album</div>
116 <div className="text-right">⏱</div>
117 </div>
118
119 <div>
120 {tracks.map((track, index) => (
121 <div
122 key={track.id}
123 className="grid grid-cols-[16px_6fr_4fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded hover:bg-white hover:bg-white/10 group"
124 >
125 <div className="flex items-center text-gray-400">
126 <span className="group-hover:hidden">{index + 1}</span>
127 <button className="hidden group-hover:block">▶</button>
128 </div>
129
130 <div className="flex items-center gap-3">
131 <img src={track.coverArt} alt={track.title} className="w-10 h-10 rounded" />
132 <div>
133 <div className="font-semibold">{track.title}</div>
134 <div className="text-sm text-gray-400">{track.artist}</div>
135 </div>
136 </div>
137
138 <div className="flex items-center text-sm text-gray-400">
139 {track.album}
140 </div>
141
142 <div className="flex items-center justify-end gap-4">
143 <button className="opacity-0 group-hover:opacity-100">
144 {track.isLiked ? 'Aimé' : 'J’aime'}
145 </button>
146 <span className="text-sm text-gray-400">
147 {formatDuration(track.duration)}
148 </span>
149 <button className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white">
150 ⋯
151 </button>
152 </div>
153 </div>
154 ))}
155 </div>
156 </div>
157 </div>
158 );
159}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. Pourquoi utilise-t-on le debounce dans la recherche ?
2. Quels types de résultats SearchResults renvoie-t-il ?
Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Clique pour construire l'appel de recherche :
- Clique dans l'ordre
Clique pour construire la recherche différée :
- Tri horizontal
Ordonne les onglets de SearchPage de gauche à droite :
- Éditeur de code
Interface de recherche
- Tri vertical
Ordonne les étapes de la recherche avec debounce dans useEffect :
- Tri vertical
Ordonne les étapes de génération de Discover Weekly :
- Clique dans l'ordre
Clique pour construire l'effacement du minuteur précédent dans le nettoyage :
- Tri horizontal
Ordonne les éléments pour filtrer les morceaux par titre sur une seule ligne :
- Tri vertical
Ordonne les étapes du fonctionnement d'une Radio Station (à partir d'un point de départ) :
- Éditeur de code
Interface de découverte musicale