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

Recherche, découverte et recommandations

4 min de lecture
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. 1. Pourquoi utilise-t-on le debounce dans la recherche ?

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

Articles utiles