Cours Vibe coding avec l'IA · Module 13: Clone de Twitch

Lecteur de stream en direct et chat

6 min de lecture
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. 1. Quelle est la principale différence entre Twitch et YouTube ?

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

Articles utiles