Cours Vibe coding avec l'IA · Module 6: Clone de YouTube
Plateforme vidéo - intégration et lecture
Dans cette leçon8
Bienvenue à bord ! Les équipages filment batailles navales et chasses au trésor, mais les vidéos traînent au fond des coffres. Construisons-leur le Cinéma du Capitaine, une plateforme vidéo de pirates sur le modèle de YouTube.
Au programme
- décrire une vidéo avec une interface TypeScript,
- créer un lecteur avec lecture/pause, barre de progression et volume,
- formater les durées et les vues,
- disposer une grille de miniatures et une page de lecture,
- intégrer une vraie vidéo YouTube.
YouTube face à Twitter et Instagram
Sur Twitter (aujourd'hui X) et Instagram, tu suis des comptes et fais défiler un fil de textes courts et de photos. YouTube repose sur des vidéos longues (des minutes, voire des heures), des chaînes de créateurs, des playlists et des abonnements. Il lui faut aussi un lecteur avec des contrôles et une liste de vidéos associées. Ce sont justement les playlists et les abonnements aux chaînes qui le distinguent d'Instagram : ils guideront ce module.
Modèle de données : l'interface Video
Avant d'afficher quoi que ce soit, définis ce qu'est une vidéo dans le code. L'interface Video réunit tout ce dont la miniature et la page de lecture ont besoin :
1interface Video {
2 id: string;
3 title: string;
4 description: string;
5 thumbnail: string;
6 duration: number; // en secondes
7 views: number;
8 likes: number;
9 dislikes: number;
10 uploadDate: Date;
11 channel: {
12 id: string;
13 name: string;
14 avatar: string;
15 subscribers: number;
16 };
17 tags: string[];
18 category: string;
19}La durée (duration) est en secondes, et channel est un objet imbriqué avec un nom, un avatar et un nombre d'abonnés. L'interface n'affiche rien, elle décrit seulement la forme des données. Note ce qui manque : pas de champs followers ni messages, qui relèvent de Twitter et pas d'une plateforme vidéo.
Le lecteur et la fonction formatTime
Le navigateur possède l'élément <video>, et les vidéos YouTube ou Vimeo s'intègrent dans un iframe. Pour commencer, simulons le lecteur afin de nous concentrer sur son état et ses contrôles :
1interface VideoPlayerProps {
2 video: Video;
3}
4
5export default function VideoPlayer({ video }: VideoPlayerProps) {
6 const [isPlaying, setIsPlaying] = useState(false);
7 const [currentTime, setCurrentTime] = useState(0);
8 const [volume, setVolume] = useState(100);
9
10 return (
11 <div className="bg-black rounded-lg overflow-hidden">
12 {/* Zone vidéo - simulée */}
13 <div className="relative aspect-video bg-gradient-to-br from-slate-800 to-slate-900 flex items-center justify-center">
14 {!isPlaying ? (
15 <button
16 onClick={() => setIsPlaying(true)}
17 className="w-20 h-20 bg-red-600 rounded-full flex items-center justify-center text-white text-4xl hover:bg-red-700 transition"
18 >
19 ▶
20 </button>
21 ) : (
22 <div className="text-white text-center">
23 <div className="text-6xl mb-4">◆</div>
24 <p className="text-2xl font-bold">{video.title}</p>
25 <p className="text-gray-400">Lecture en cours...</p>
26 </div>
27 )}
28
29 {/* Contrôles superposés */}
30 <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
31 {/* Barre de progression */}
32 <div className="mb-3">
33 <input
34 type="range"
35 min="0"
36 max={video.duration}
37 value={currentTime}
38 onChange={(e) => setCurrentTime(parseInt(e.target.value))}
39 className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
40 />
41 <div className="flex justify-between text-xs text-white mt-1">
42 <span>{formatTime(currentTime)}</span>
43 <span>{formatTime(video.duration)}</span>
44 </div>
45 </div>
46
47 {/* Contrôles */}
48 <div className="flex items-center gap-4">
49 <button
50 onClick={() => setIsPlaying(!isPlaying)}
51 className="text-white text-2xl hover:scale-110 transition"
52 >
53 {isPlaying ? '⏸' : '▶'}
54 </button>
55
56 <div className="flex items-center gap-2">
57 <button className="text-white text-xl">Son</button>
58 <input
59 type="range"
60 min="0"
61 max="100"
62 value={volume}
63 onChange={(e) => setVolume(parseInt(e.target.value))}
64 className="w-20 h-1 bg-gray-600 rounded-lg"
65 />
66 </div>
67
68 <div className="flex-1" />
69
70 <button className="text-white text-xl hover:scale-110 transition">
71 Paramètres
72 </button>
73 <button className="text-white text-xl hover:scale-110 transition">
74 Plein écran
75 </button>
76 </div>
77 </div>
78 </div>
79 </div>
80 );
81}
82
83function formatTime(seconds: number): string {
84 const mins = Math.floor(seconds / 60);
85 const secs = seconds % 60;
86 return `${mins}:${secs.toString().padStart(2, '0')}`;
87}Trois états pilotent l'ensemble : isPlaying indique si la vidéo est en lecture, currentTime est la position sur la barre de progression et volume le volume de 0 à 100. La barre et le curseur de volume sont de simples <input type="range">. Les deux boutons vides à droite attendent les icônes des réglages et du plein écran. Le jeu est complet : lecture/pause, barre de progression, volume et plein écran. Ce code change seulement l'état, il ne lit encore aucun vrai fichier.
formatTime convertit des secondes au format MM:SS. Pour 125 secondes, Math.floor(125 / 60) donne 2 minutes, 125 % 60 donne 5 secondes et padStart(2, '0') ajoute un zéro devant : le résultat est « 2:05 ».
La miniature de vidéo
Une miniature, c'est une image avec la durée dans un coin et les infos de la chaîne en dessous. Voici le composant VideoThumbnail :
1function VideoThumbnail({ video }: { video: Video }) {
2 return (
3 <div className="group cursor-pointer">
4 {/* Miniature */}
5 <div className="relative aspect-video bg-gray-200 rounded-lg overflow-hidden mb-2">
6 <img
7 src={video.thumbnail}
8 alt={video.title}
9 className="w-full h-full object-cover group-hover:scale-105 transition-transform"
10 />
11
12 {/* Badge de durée */}
13 <div className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-2 py-1 rounded">
14 {formatTime(video.duration)}
15 </div>
16
17 {/* Superposition de lecture au survol */}
18 <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-opacity flex items-center justify-center">
19 <div className="opacity-0 group-hover:opacity-100 transition-opacity text-white text-4xl">
20 ▶
21 </div>
22 </div>
23 </div>
24
25 {/* Infos de la vidéo */}
26 <div className="flex gap-3">
27 {/* Avatar de la chaîne */}
28 <img
29 src={video.channel.avatar}
30 alt={video.channel.name}
31 className="w-9 h-9 rounded-full"
32 />
33
34 <div className="flex-1">
35 {/* Titre */}
36 <h3 className="font-semibold text-sm line-clamp-2 mb-1">
37 {video.title}
38 </h3>
39
40 {/* Nom de la chaîne */}
41 <p className="text-xs text-gray-600 mb-1">
42 {video.channel.name}
43 </p>
44
45 {/* Vues & date */}
46 <p className="text-xs text-gray-600">
47 {formatViews(video.views)} vues • {formatDate(video.uploadDate)}
48 </p>
49 </div>
50 </div>
51 </div>
52 );
53}
54
55function formatViews(count: number): string {
56 if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
57 if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
58 return count.toString();
59}
60
61function formatDate(date: Date): string {
62 const days = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
63 if (days === 0) return "aujourd'hui";
64 if (days === 1) return 'hier';
65 if (days < 7) return `il y a ${days} jours`;
66 if (days < 30) return `il y a ${Math.floor(days / 7)} semaines`;
67 if (days < 365) return `il y a ${Math.floor(days / 30)} mois`;
68 return `il y a ${Math.floor(days / 365)} ans`;
69}La classe group sur le conteneur permet à group-hover:scale-105 d'agrandir l'image quand le curseur survole toute la carte, pas seulement la photo. Le badge de durée, formatTime(video.duration), se place en bas à droite grâce à absolute bottom-2 right-2. formatViews raccourcit les nombres : 1500 devient « 1.5K » et 2000000 « 2.0M », car toFixed(1) garde toujours un chiffre après le point. Les données restent les mêmes, seule leur écriture change.
Attention avec Tailwind 4 : les classes bg-opacity-* ont disparu. La transparence s'écrit avec une barre oblique, par exemple bg-black/80, comme dans la surcouche du lecteur.
La grille de vidéos
Le composant VideoGrid transforme un tableau de vidéos en grille de miniatures :
1export default function VideoGrid({ videos }: { videos: Video[] }) {
2 return (
3 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
4 {videos.map(video => (
5 <VideoThumbnail key={video.id} video={video} />
6 ))}
7 </div>
8 );
9}videos.map((video) => <VideoThumbnail video={video} />) crée une carte par vidéo, et key={video.id} permet à React de reconnaître les cartes d'un rendu à l'autre. Les classes grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 donnent une colonne sur téléphone et quatre sur grand écran. Seule la disposition change, les vidéos restent les mêmes.
La page de lecture
WatchPage associe le lecteur aux informations sur la vidéo et la chaîne :
1export default function WatchPage({ video }: { video: Video }) {
2 const [isSubscribed, setIsSubscribed] = useState(false);
3
4 return (
5 <div className="max-w-7xl mx-auto p-4">
6 <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
7 {/* Colonne principale - Lecteur vidéo */}
8 <div className="lg:col-span-2">
9 <VideoPlayer video={video} />
10
11 {/* Infos de la vidéo */}
12 <div className="mt-4">
13 <h1 className="text-xl font-bold mb-2">{video.title}</h1>
14
15 <div className="flex items-center justify-between">
16 <div className="flex items-center gap-3">
17 <img
18 src={video.channel.avatar}
19 alt={video.channel.name}
20 className="w-12 h-12 rounded-full"
21 />
22 <div>
23 <h3 className="font-semibold">{video.channel.name}</h3>
24 <p className="text-sm text-gray-600">
25 {formatViews(video.channel.subscribers)} abonnés
26 </p>
27 </div>
28 <button
29 onClick={() => setIsSubscribed(!isSubscribed)}
30 className={`px-4 py-2 rounded-full font-semibold transition ${
31 isSubscribed
32 ? 'bg-gray-200 text-gray-700'
33 : 'bg-red-600 text-white hover:bg-red-700'
34 }`}
35 >
36 {isSubscribed ? 'Abonné' : "S'abonner"}
37 </button>
38 </div>
39
40 {/* J’aime/Je n’aime pas */}
41 <div className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-2">
42 <button className="flex items-center gap-1 hover:text-blue-600">
43 {formatViews(video.likes)}
44 </button>
45 <div className="w-px h-6 bg-gray-300" />
46 <button className="flex items-center gap-1 hover:text-red-600">
47 Je n’aime pas
48 </button>
49 </div>
50 </div>
51
52 {/* Description */}
53 <div className="mt-4 bg-gray-100 rounded-lg p-4">
54 <p className="text-sm font-semibold mb-2">
55 {formatViews(video.views)} vues • {formatDate(video.uploadDate)}
56 </p>
57 <p className="text-sm whitespace-pre-wrap">{video.description}</p>
58 </div>
59 </div>
60 </div>
61
62 {/* Barre latérale - Vidéos associées */}
63 <div className="space-y-2">
64 <h2 className="font-bold mb-3">Vidéos associées</h2>
65 {/* Liste des vidéos associées */}
66 </div>
67 </div>
68 </div>
69 );
70}De haut en bas : le lecteur, le titre, la ligne de la chaîne avec le bouton S'abonner, puis la description. Le bouton appelle setIsSubscribed(!isSubscribed) : chaque clic bascule l'état et le libellé entre S'abonner et Abonné, sans recharger la page. La colonne latérale attend les vidéos associées.
Une vraie vidéo YouTube
Dans un vrai projet, inutile d'héberger les vidéos toi-même : YouTube fournit un lecteur prêt à intégrer.
1function YouTubeEmbed({ videoId }: { videoId: string }) {
2 return (
3 <div className="relative aspect-video">
4 <iframe
5 src={`https://www.youtube.com/embed/${videoId}`}
6 title="YouTube video player"
7 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
8 allowFullScreen
9 className="absolute inset-0 w-full h-full"
10 />
11 </div>
12 );
13}
14
15// Utilisation :
16<YouTubeEmbed videoId="dQw4w9WgXcQ" />L'adresse https://www.youtube.com/embed/ suivie de ${videoId} désigne une vidéo précise, et aspect-video garde le format 16:9. Le composant ne copie pas la vidéo sur ton serveur, il intègre seulement le lecteur de YouTube. YouTube exige que la page du lecteur envoie l'en-tête Referer : ne lui impose pas la politique no-referrer, sinon la vidéo ne démarrera pas.
Conseil du Capitaine Barbe-Rouge : quand tu demandes un composant à l'IA, colle-lui l'interface Video. Le modèle n'aura pas à deviner les noms des champs, et tu verras tout de suite s'il ajoute un champ absent de tes données.
Dans l'éditeur ci-dessous, tu trouves une grille de miniatures pirates. Change une durée ou un nombre de vues, vérifie ce que renvoient formatTime et formatViews, puis passe aux exercices.
Code de cette leçon : App.jsx
1// Grille de vidéos avec miniatures
2const videos = [
3 { id: 1, title: 'Bataille contre le Kraken', channel: 'Capitaine Redbeard', views: 234000, duration: 720, thumb: '' },
4 { id: 2, title: 'Comment trouver un trésor', channel: 'Morgan Blackheart', views: 156000, duration: 480, thumb: '' },
5 { id: 3, title: 'Réparation du navire', channel: 'Jack Silverhand', views: 89000, duration: 600, thumb: '' },
6 { id: 4, title: 'Carte au trésor', channel: 'Redbeard', views: 312000, duration: 360, thumb: '' },
7];
8
9function formatTime(sec) {
10 const m = Math.floor(sec / 60);
11 const s = sec % 60;
12 return `${m}:${s.toString().padStart(2, '0')}`;
13}
14
15function formatViews(n) {
16 return n >= 1000000 ? `${(n/1000000).toFixed(1)}M` : `${(n/1000).toFixed(1)}K`;
17}
18
19function VideoThumbnail({ video }) {
20 return (
21 <div className="group cursor-pointer">
22 <div className="relative aspect-video bg-gradient-to-br from-blue-900 to-purple-900 rounded-lg mb-2 overflow-hidden flex items-center justify-center text-7xl group-hover:scale-105 transition">
23 {video.thumb}
24 <div className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-2 py-1 rounded">
25 {formatTime(video.duration)}
26 </div>
27 <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition flex items-center justify-center">
28 <div className="opacity-0 group-hover:opacity-100 text-white text-4xl">▶</div>
29 </div>
30 </div>
31 <h3 className="font-semibold text-sm line-clamp-2 mb-1">{video.title}</h3>
32 <p className="text-xs text-gray-600">{video.channel}</p>
33 <p className="text-xs text-gray-600">{formatViews(video.views)} vues</p>
34 </div>
35 );
36}
37
38export default function App() {
39 return (
40 <div className="min-h-screen bg-gray-100 p-8">
41 <h1 className="text-3xl font-bold mb-6">Le Cinéma du Capitaine</h1>
42 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
43 {videos.map(v => <VideoThumbnail key={v.id} video={v} />)}
44 </div>
45 </div>
46 );
47}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. Quel champ ne fait PAS partie de l'interface Video ?
2. Comment formate-t-on 125 secondes au format MM:SS ?
Voici 2 des 8 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les lignes du corps de la fonction formatTime (de haut en bas) :
- Clique dans l'ordre
Construis l'appel à la fonction qui formate la durée de la vidéo :
- Éditeur de code
Carte de miniature vidéo
- Tri vertical
Ordonne les sections de la page de lecture (WatchPage) de haut en bas :
- Éditeur de code
Lecteur vidéo interactif
- Tri vertical
Ordonne les lignes du composant YouTubeEmbed avec un iframe :
- Tri horizontal
Ordonne la valeur de l'attribut src pour l'intégration YouTube (une seule ligne) :
- Clique dans l'ordre
Construis l'expression qui affiche les miniatures dans VideoGrid :