Cours Vibe coding avec l'IA · Module 13: Clone de Twitch
Lecteur de stream en direct et chat
Dans cette leçon5
Bienvenue ! Il est temps de créer En direct du pont - une plateforme de streaming en direct comme Twitch !
Ce sera une plateforme de streaming en direct - lecteur vidéo, chat en direct, emotes, abonnements et clips.
Twitch vs YouTube/TikTok
YouTube :
- Vidéos préenregistrées (upload → visionnage)
- VOD (Video on Demand)
- Contenu long
TikTok :
- Courtes vidéos verticales
- Fil piloté par l'algorithme
- Pas principalement du direct
Twitch :
- Streaming EN DIRECT (diffusion en temps réel)
- Chat interactif avec des emotes
- Abonnements et bits (dons)
- Catégories et tags
- Clips (moments forts extraits des streams)
- VOD (enregistrements des streams passés)
- Raids (envoyer ses spectateurs vers une autre chaîne)
- Système de followers/abonnés
Structure d'un stream
1interface Stream {
2 id: string;
3 channelId: string;
4 channel: Channel;
5
6 // Stream info
7 title: string;
8 category: Category;
9 tags: string[];
10
11 // Video
12 videoUrl: string; // HLS or RTMP stream URL
13 thumbnailUrl: string;
14 quality: StreamQuality[];
15
16 // Metadata
17 isLive: boolean;
18 viewerCount: number;
19 startedAt: Date;
20 duration: number; // seconds (if VOD)
21
22 // Chat
23 chatEnabled: boolean;
24 slowMode: boolean; // limit message frequency
25 subscribersOnly: boolean;
26 emotesOnly: boolean;
27}
28
29interface Channel {
30 id: string;
31 username: string;
32 displayName: string;
33 avatar: string;
34 banner?: string;
35 description: string;
36
37 // Stats
38 followers: number;
39 subscribers: number;
40 totalViews: number;
41
42 // Monetization
43 isPartner: boolean;
44 isAffiliate: boolean;
45
46 // Current stream
47 isLive: boolean;
48 currentStream?: Stream;
49}
50
51interface StreamQuality {
52 label: string; // "1080p60", "720p60", "480p", "360p", "160p", "Audio only"
53 resolution: { width: number; height: number };
54 bitrate: number;
55 fps: number;
56}
57
58interface Category {
59 id: string;
60 name: string;
61 boxArtUrl: string;
62 viewerCount: number;
63 tags: string[];
64}Lecteur de stream
1'use client';
2
3import { useState, useRef, useEffect } from 'react';
4
5interface StreamPlayerProps {
6 stream: Stream;
7 onQualityChange: (quality: string) => void;
8}
9
10export default function StreamPlayer({ stream, onQualityChange }: StreamPlayerProps) {
11 const [isPlaying, setIsPlaying] = useState(true);
12 const [isMuted, setIsMuted] = useState(false);
13 const [volume, setVolume] = useState(50);
14 const [selectedQuality, setSelectedQuality] = useState('1080p60');
15 const [isFullscreen, setIsFullscreen] = useState(false);
16 const videoRef = useRef<HTMLVideoElement>(null);
17
18 const togglePlay = () => {
19 if (videoRef.current) {
20 if (isPlaying) {
21 videoRef.current.pause();
22 } else {
23 videoRef.current.play();
24 }
25 setIsPlaying(!isPlaying);
26 }
27 };
28
29 const toggleMute = () => {
30 if (videoRef.current) {
31 videoRef.current.muted = !isMuted;
32 setIsMuted(!isMuted);
33 }
34 };
35
36 const handleVolumeChange = (newVolume: number) => {
37 if (videoRef.current) {
38 videoRef.current.volume = newVolume / 100;
39 setVolume(newVolume);
40 setIsMuted(newVolume === 0);
41 }
42 };
43
44 const changeQuality = (quality: string) => {
45 setSelectedQuality(quality);
46 onQualityChange(quality);
47 };
48
49 const toggleFullscreen = () => {
50 if (!document.fullscreenElement) {
51 videoRef.current?.requestFullscreen();
52 setIsFullscreen(true);
53 } else {
54 document.exitFullscreen();
55 setIsFullscreen(false);
56 }
57 };
58
59 return (
60 <div className="relative bg-black group">
61 {/* Video */}
62 <video
63 ref={videoRef}
64 src={stream.videoUrl}
65 className="w-full aspect-video"
66 autoPlay
67 playsInline
68 />
69
70 {/* Live Badge */}
71 {stream.isLive && (
72 <div className="absolute top-4 left-4 flex items-center gap-2">
73 <div className="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded">
74 LIVE
75 </div>
76 <div className="px-2 py-1 bg-black/70 text-white text-xs rounded">
77 {stream.viewerCount.toLocaleString()} viewers
78 </div>
79 </div>
80 )}
81
82 {/* Controls Overlay */}
83 <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
84 <div className="flex items-center gap-4">
85 {/* Play/Pause */}
86 <button
87 onClick={togglePlay}
88 className="text-white text-2xl hover:scale-110 transition"
89 >
90 {isPlaying ? '⏸' : '▶'}
91 </button>
92
93 {/* Volume */}
94 <button
95 onClick={toggleMute}
96 className="text-white text-xl hover:scale-110 transition"
97 >
98 {isMuted || volume === 0 ? 'Muet' : volume < 50 ? 'Bas' : 'Fort'}
99 </button>
100
101 <input
102 type="range"
103 min={0}
104 max={100}
105 value={isMuted ? 0 : volume}
106 onChange={(e) => handleVolumeChange(parseInt(e.target.value))}
107 className="w-20 h-1 bg-gray-600 rounded-lg"
108 />
109
110 <div className="flex-1" />
111
112 {/* Quality Selector */}
113 <div className="relative group/quality">
114 <button className="px-3 py-1 bg-gray-800/80 text-white text-sm rounded hover:bg-gray-800/100">
115 {selectedQuality}
116 </button>
117 <div className="absolute bottom-full right-0 mb-2 bg-gray-900 rounded overflow-hidden opacity-0 group-hover/quality:opacity-100 transition-opacity">
118 {stream.quality.map(q => (
119 <button
120 key={q.label}
121 onClick={() => changeQuality(q.label)}
122 className={`
123 block w-full text-left px-4 py-2 text-white text-sm hover:bg-purple-600
124 ${selectedQuality === q.label ? 'bg-purple-700' : ''}
125 `}
126 >
127 {q.label}
128 </button>
129 ))}
130 </div>
131 </div>
132
133 {/* Fullscreen */}
134 <button
135 onClick={toggleFullscreen}
136 className="text-white text-xl hover:scale-110 transition"
137 >
138 {isFullscreen ? 'Quitter le plein écran' : 'Plein écran'}
139 </button>
140 </div>
141 </div>
142 </div>
143 );
144}Chat en direct
1interface ChatMessage {
2 id: string;
3 userId: string;
4 username: string;
5 displayName: string;
6 userBadges: Badge[];
7 message: string;
8 emotes: Emote[];
9 timestamp: Date;
10 isHighlighted: boolean; // paid highlight
11 isPinned: boolean;
12}
13
14interface Badge {
15 type: 'broadcaster' | 'moderator' | 'subscriber' | 'vip' | 'partner';
16 label: string;
17 imageUrl: string;
18}
19
20interface Emote {
21 id: string;
22 name: string; // e.g., "Kappa", "PogChamp"
23 imageUrl: string;
24 startIndex: number;
25 endIndex: number;
26}
27
28'use client';
29
30export default function LiveChat({ streamId }: { streamId: string }) {
31 const [messages, setMessages] = useState<ChatMessage[]>([]);
32 const [inputValue, setInputValue] = useState('');
33 const [showEmotePicker, setShowEmotePicker] = useState(false);
34
35 const sendMessage = () => {
36 if (!inputValue.trim()) return;
37
38 const newMessage: ChatMessage = {
39 id: Date.now().toString(),
40 userId: 'user123',
41 username: 'currentUser',
42 displayName: 'Current User',
43 userBadges: [],
44 message: inputValue,
45 emotes: [],
46 timestamp: new Date(),
47 isHighlighted: false,
48 isPinned: false
49 };
50
51 setMessages([...messages, newMessage]);
52 setInputValue('');
53 };
54
55 const parseMessageWithEmotes = (msg: ChatMessage) => {
56 if (msg.emotes.length === 0) return msg.message;
57
58 const parts: Array<{ text?: string; emote?: Emote }> = [];
59 let lastIndex = 0;
60
61 msg.emotes
62 .sort((a, b) => a.startIndex - b.startIndex)
63 .forEach(emote => {
64 if (emote.startIndex > lastIndex) {
65 parts.push({
66 text: msg.message.slice(lastIndex, emote.startIndex)
67 });
68 }
69
70 parts.push({ emote });
71 lastIndex = emote.endIndex;
72 });
73
74 if (lastIndex < msg.message.length) {
75 parts.push({ text: msg.message.slice(lastIndex) });
76 }
77
78 return parts.map((part, i) =>
79 part.emote ? (
80 <img
81 key={i}
82 src={part.emote.imageUrl}
83 alt={part.emote.name}
84 className="inline h-6 align-middle"
85 />
86 ) : (
87 <span key={i}>{part.text}</span>
88 )
89 );
90 };
91
92 return (
93 <div className="h-full bg-gray-900 flex flex-col">
94 {/* Chat Header */}
95 <div className="p-3 border-b border-gray-800">
96 <h3 className="text-white font-semibold">STREAM CHAT</h3>
97 </div>
98
99 {/* Messages */}
100 <div className="flex-1 overflow-y-auto p-3 space-y-2">
101 {messages.map(msg => (
102 <div
103 key={msg.id}
104 className={`
105 text-sm
106 ${msg.isHighlighted ? 'bg-purple-900/30 p-2 rounded' : ''}
107 `}
108 >
109 {/* Badges */}
110 {msg.userBadges.map(badge => (
111 <img
112 key={badge.type}
113 src={badge.imageUrl}
114 alt={badge.label}
115 className="inline h-4 mr-1"
116 title={badge.label}
117 />
118 ))}
119
120 {/* Username */}
121 <span className="font-semibold text-purple-400">
122 {msg.displayName}:
123 </span>
124
125 {/* Message */}
126 <span className="text-white ml-1">
127 {parseMessageWithEmotes(msg)}
128 </span>
129 </div>
130 ))}
131 </div>
132
133 {/* Input */}
134 <div className="p-3 border-t border-gray-800">
135 <div className="flex gap-2">
136 <button
137 onClick={() => setShowEmotePicker(!showEmotePicker)}
138 className="p-2 text-gray-400 hover:text-white"
139 >
140 :)
141 </button>
142
143 <input
144 type="text"
145 value={inputValue}
146 onChange={(e) => setInputValue(e.target.value)}
147 onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
148 placeholder="Send a message"
149 className="flex-1 bg-gray-800 text-white px-3 py-2 rounded outline-none focus:ring-2 focus:ring-purple-500"
150 />
151
152 <button
153 onClick={sendMessage}
154 disabled={!inputValue.trim()}
155 className="px-4 py-2 bg-purple-600 text-white rounded font-semibold hover:bg-purple-700 disabled:bg-gray-700 disabled:cursor-not-allowed"
156 >
157 Chat
158 </button>
159 </div>
160 </div>
161 </div>
162 );
163}Récapitulatif
Lecteur de stream - lecture vidéo, play/pause, volume Sélecteur de qualité - 1080p60, 720p60, 480p, etc. Badge live - indicateur LIVE + nombre de spectateurs Plein écran - basculer en mode plein écran Chat en direct - messages en temps réel Emotes du chat - analyser et afficher les emotes Badges d'utilisateur - streamer, modo, abonné, VIP Messages mis en avant - highlights payants
Dans l'exercice suivant, on ajoutera la page de chaîne et les abonnements !
À bientôt !
Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5interface Stream {
6 title: string;
7 isLive: boolean;
8 viewerCount: number;
9 quality: string[];
10}
11
12const mockStream: Stream = {
13 title: 'Aventures de pirates - EN DIRECT !',
14 isLive: true,
15 viewerCount: 1247,
16 quality: ['1080p60', '720p60', '480p', '360p', 'Audio only']
17};
18
19export default function StreamPlayer() {
20 const [isPlaying, setIsPlaying] = useState(true);
21 const [isMuted, setIsMuted] = useState(false);
22 const [volume, setVolume] = useState(50);
23 const [selectedQuality, setSelectedQuality] = useState('1080p60');
24 const [showQuality, setShowQuality] = useState(false);
25
26 const toggleMute = () => setIsMuted(!isMuted);
27
28 return (
29 <div className="min-h-screen bg-black flex items-center justify-center">
30 <div className="w-full max-w-4xl">
31 <div className="relative bg-gray-900 group">
32 {/* Video Area */}
33 <div className="aspect-video bg-gradient-to-br from-purple-900 to-black flex items-center justify-center text-white text-2xl">
34 {isPlaying ? 'LIVE STREAM' : '⏸ PAUSED'}
35 </div>
36
37 {/* Live Badge */}
38 {mockStream.isLive && (
39 <div className="absolute top-4 left-4 flex items-center gap-2">
40 <div className="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded">
41 LIVE
42 </div>
43 <div className="px-2 py-1 bg-black/70 text-white text-xs rounded">
44 {mockStream.viewerCount.toLocaleString()} viewers
45 </div>
46 </div>
47 )}
48
49 {/* Controls Overlay */}
50 <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
51 <div className="flex items-center gap-4">
52 {/* Play/Pause */}
53 <button
54 onClick={() => setIsPlaying(!isPlaying)}
55 className="text-white text-2xl hover:scale-110 transition"
56 >
57 {isPlaying ? '⏸' : '▶'}
58 </button>
59
60 {/* Volume */}
61 <button onClick={toggleMute} className="text-white text-xl">
62 {isMuted || volume === 0 ? 'Muet' : volume < 50 ? 'Bas' : 'Fort'}
63 </button>
64
65 <input
66 type="range"
67 min={0}
68 max={100}
69 value={isMuted ? 0 : volume}
70 onChange={(e) => setVolume(parseInt(e.target.value))}
71 className="w-20"
72 />
73
74 <div className="flex-1" />
75
76 {/* Quality */}
77 <div className="relative">
78 <button
79 onClick={() => setShowQuality(!showQuality)}
80 className="px-3 py-1 bg-gray-800/80 text-white text-sm rounded hover:bg-gray-800/100"
81 >
82 {selectedQuality}
83 </button>
84 {showQuality && (
85 <div className="absolute bottom-full right-0 mb-2 bg-gray-900 rounded overflow-hidden">
86 {mockStream.quality.map(q => (
87 <button
88 key={q}
89 onClick={() => {
90 setSelectedQuality(q);
91 setShowQuality(false);
92 }}
93 className={`block w-full text-left px-4 py-2 text-white text-sm hover:bg-purple-600 ${selectedQuality === q ? 'bg-purple-700' : ''}`}
94 >
95 {q}
96 </button>
97 ))}
98 </div>
99 )}
100 </div>
101
102 {/* Fullscreen */}
103 <button className="text-white text-xl hover:scale-110 transition">
104 Plein écran
105 </button>
106 </div>
107 </div>
108 </div>
109 </div>
110 </div>
111 );
112}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. Quelle est la principale différence entre Twitch et YouTube ?
2. Quelles options de qualité un stream Twitch propose-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
Construis le changement d'état de lecture dans togglePlay : setIsPlaying(!isPlaying)
- Tri horizontal
Range les contrôles dans le lecteur de stream :
- Clique dans l'ordre
Construis le hook d'état de lecture : useState(true) pour isPlaying
- Éditeur de code
Construis un lecteur de stream avec des contrôles vidéo
- Tri vertical
Ordonne les éléments affichés dans un message de chat (le haut de la structure en premier) :
- Éditeur de code
Crée une interface de chat en temps réel
- Tri vertical
Ordonne les étapes de la fonction sendMessage dans le chat en direct :
- Clique dans l'ordre
Construis l'appel de mise à jour de l'état muet : setIsMuted(!isMuted)