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

Catégories, clips et découverte

5 min de lecture
Dans cette leçon4

Twitch propose de la découverte - parcourir par catégorie, clips, VOD.

Parcourir les catégories

1interface BrowseData {
2  liveChannels: Channel[];
3  categories: Category[];
4  clips: Clip[];
5  vods: VOD[];
6}
7
8// Category page
9'use client';
10
11export default function BrowsePage() {
12  const [activeTab, setActiveTab] = useState<'categories' | 'live' | 'clips'>('categories');
13
14  return (
15    <div className="min-h-screen bg-gray-950 text-white p-8">
16      <h1 className="text-3xl font-bold mb-6">Browse</h1>
17
18      {/* Tabs */}
19      <div className="flex gap-6 mb-8 border-b border-gray-800">
20        {[
21          { key: 'categories', label: 'Categories' },
22          { key: 'live', label: 'Live Channels' },
23          { key: 'clips', label: 'Clips' }
24        ].map(tab => (
25          <button
26            key={tab.key}
27            onClick={() => setActiveTab(tab.key as any)}
28            className={`
29              pb-3 font-semibold
30              ${activeTab === tab.key
31                ? 'border-b-2 border-purple-500'
32                : 'text-gray-400 hover:text-white'
33              }
34            `}
35          >
36            {tab.label}
37          </button>
38        ))}
39      </div>
40
41      {/* Content */}
42      {activeTab === 'categories' && (
43        <div className="grid grid-cols-6 gap-4">
44          {mockCategories.map(category => (
45            <CategoryCard key={category.id} category={category} />
46          ))}
47        </div>
48      )}
49
50      {activeTab === 'live' && (
51        <div className="grid grid-cols-4 gap-4">
52          {mockLiveChannels.map(channel => (
53            <LiveChannelCard key={channel.id} channel={channel} />
54          ))}
55        </div>
56      )}
57
58      {activeTab === 'clips' && (
59        <div className="grid grid-cols-4 gap-4">
60          {mockClips.map(clip => (
61            <ClipCard key={clip.id} clip={clip} />
62          ))}
63        </div>
64      )}
65    </div>
66  );
67}
68
69function CategoryCard({ category }: { category: Category }) {
70  return (
71    <div className="cursor-pointer group">
72      <div className="relative mb-2 overflow-hidden rounded">
73        <img
74          src={category.boxArtUrl}
75          alt={category.name}
76          className="w-full aspect-[3/4] object-cover group-hover:scale-110 transition"
77        />
78        <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-2">
79          <div className="text-xs text-gray-300">
80            {category.viewerCount.toLocaleString()} viewers
81          </div>
82        </div>
83      </div>
84      <h3 className="font-semibold">{category.name}</h3>
85      <div className="flex flex-wrap gap-1 mt-1">
86        {category.tags.slice(0, 2).map(tag => (
87          <span key={tag} className="text-xs bg-gray-800 px-2 py-1 rounded">
88            {tag}
89          </span>
90        ))}
91      </div>
92    </div>
93  );
94}

Système de clips

1interface Clip {
2  id: string;
3  streamId: string;
4  channelId: string;
5  channel: Channel;
6
7  // Content
8  title: string;
9  videoUrl: string;
10  thumbnailUrl: string;
11  duration: number; // seconds (max 60s)
12
13  // Metadata
14  createdBy: string;
15  createdAt: Date;
16  views: number;
17  category: Category;
18
19  // Source
20  streamTimestamp: number; // position in original stream
21}
22
23// Create clip from live stream
24function createClip(
25  streamId: string,
26  currentTimestamp: number,
27  duration: number = 30
28): Clip {
29  // Capture video from (currentTimestamp - duration/2) to (currentTimestamp + duration/2)
30  // Max 60 seconds
31
32  return {
33    id: generateId(),
34    streamId,
35    channelId: getCurrentChannel().id,
36    channel: getCurrentChannel(),
37    title: `${getCurrentChannel().displayName} - Clip`,
38    videoUrl: `/clips/${generateId()}.mp4`,
39    thumbnailUrl: `/clips/${generateId()}-thumb.jpg`,
40    duration: Math.min(duration, 60),
41    createdBy: getCurrentUser().id,
42    createdAt: new Date(),
43    views: 0,
44    category: getCurrentStream().category,
45    streamTimestamp: currentTimestamp
46  };
47}
48
49// Clip player
50'use client';
51
52export default function ClipPlayer({ clip }: { clip: Clip }) {
53  const [isPlaying, setIsPlaying] = useState(false);
54
55  return (
56    <div className="bg-gray-900 rounded overflow-hidden">
57      {/* Video */}
58      <div className="relative">
59        <video
60          src={clip.videoUrl}
61          poster={clip.thumbnailUrl}
62          className="w-full aspect-video"
63          controls
64          autoPlay={isPlaying}
65        />
66      </div>
67
68      {/* Info */}
69      <div className="p-4">
70        <div className="flex items-start gap-3 mb-3">
71          <img
72            src={clip.channel.avatar}
73            alt={clip.channel.displayName}
74            className="w-10 h-10 rounded-full"
75          />
76          <div className="flex-1">
77            <h3 className="font-semibold">{clip.title}</h3>
78            <p className="text-sm text-gray-400">{clip.channel.displayName}</p>
79          </div>
80        </div>
81
82        <div className="flex items-center gap-4 text-sm text-gray-400">
83          <span>{clip.views.toLocaleString()} views</span>
84          <span>•</span>
85          <span>{new Date(clip.createdAt).toLocaleDateString()}</span>
86          <span>•</span>
87          <span>{clip.category.name}</span>
88        </div>
89
90        {/* Actions */}
91        <div className="flex gap-2 mt-4">
92          <button className="flex-1 px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">
93            Share
94          </button>
95          <button className="flex-1 px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">
96            Watch Full Video
97          </button>
98        </div>
99      </div>
100    </div>
101  );
102}

VOD (Video on Demand)

1interface VOD {
2  id: string;
3  channelId: string;
4  channel: Channel;
5
6  // Content
7  title: string;
8  videoUrl: string;
9  thumbnailUrl: string;
10  duration: number; // seconds
11
12  // Metadata
13  streamedAt: Date;
14  views: number;
15  category: Category;
16
17  // Chapters (optional)
18  chapters?: Chapter[];
19}
20
21interface Chapter {
22  id: string;
23  title: string;
24  timestamp: number; // seconds from start
25  duration: number;
26}
27
28// VOD with chapters
29'use client';
30
31export default function VODPlayer({ vod }: { vod: VOD }) {
32  const [currentTime, setCurrentTime] = useState(0);
33  const [showChapters, setShowChapters] = useState(false);
34
35  const getCurrentChapter = () => {
36    if (!vod.chapters) return null;
37
38    return vod.chapters.find((chapter, i) => {
39      const nextChapter = vod.chapters![i + 1];
40      return currentTime >= chapter.timestamp &&
41        (!nextChapter || currentTime < nextChapter.timestamp);
42    });
43  };
44
45  const currentChapter = getCurrentChapter();
46
47  return (
48    <div className="bg-gray-900 rounded overflow-hidden">
49      <video
50        src={vod.videoUrl}
51        poster={vod.thumbnailUrl}
52        className="w-full aspect-video"
53        controls
54        onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
55      />
56
57      {/* Chapter indicator */}
58      {currentChapter && (
59        <div className="bg-gray-800 px-4 py-2 text-sm">
60          {currentChapter.title}
61        </div>
62      )}
63
64      {/* Chapters list */}
65      {vod.chapters && (
66        <div>
67          <button
68            onClick={() => setShowChapters(!showChapters)}
69            className="w-full px-4 py-2 bg-gray-800 text-left hover:bg-gray-700"
70          >
71            Chapters ({vod.chapters.length})
72          </button>
73
74          {showChapters && (
75            <div className="max-h-60 overflow-y-auto">
76              {vod.chapters.map(chapter => (
77                <button
78                  key={chapter.id}
79                  className={`
80                    w-full px-4 py-2 text-left hover:bg-gray-800
81                    ${currentChapter?.id === chapter.id ? 'bg-gray-800' : ''}
82                  `}
83                >
84                  <div className="flex items-center gap-3">
85                    <span className="text-sm text-gray-400">
86                      {formatTime(chapter.timestamp)}
87                    </span>
88                    <span className="flex-1">{chapter.title}</span>
89                  </div>
90                </button>
91              ))}
92            </div>
93          )}
94        </div>
95      )}
96    </div>
97  );
98}
99
100function formatTime(seconds: number): string {
101  const h = Math.floor(seconds / 3600);
102  const m = Math.floor((seconds % 3600) / 60);
103  const s = Math.floor(seconds % 60);
104
105  if (h > 0) {
106    return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
107  }
108  return `${m}:${s.toString().padStart(2, '0')}`;
109}

Récapitulatif

Parcourir les catégories - box art, spectateurs, tags Grille des chaînes en direct - streams actifs par catégorie Système de clips - créer des clips depuis les streams (max 60s) Lecteur de clip - lire un clip, voir les infos, partager VOD - streams enregistrés Chapitres de VOD - navigation par timestamp Cartes de catégorie - découverte visuelle Clips tendance - clips les plus vus

Tu as maintenant un clone complet de Twitch avec streaming en direct, chat, abonnements, clips et VOD !

À bientôt !

Code de cette leçon : App.tsx
1'use client';
2
3import { useState } from 'react';
4
5interface Channel {
6  displayName: string;
7  description: string;
8  avatar: string;
9  banner: string;
10  followers: number;
11  totalViews: number;
12  isLive: boolean;
13  isPartner: boolean;
14}
15
16const mockChannel: Channel = {
17  displayName: 'Capitaine Barbe-Rouge',
18  description: 'Je streame des aventures de pirates et des tutos de code !',
19  avatar: 'https://i.pravatar.cc/150?img=12',
20  banner: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1200&h=400&fit=crop',
21  followers: 15247,
22  totalViews: 1234567,
23  isLive: true,
24  isPartner: true
25};
26
27export default function ChannelPage() {
28  const [isFollowing, setIsFollowing] = useState(false);
29  const [isSubscribed, setIsSubscribed] = useState(false);
30  const [activeTab, setActiveTab] = useState<'home' | 'videos' | 'about'>('home');
31
32  return (
33    <div className="min-h-screen bg-gray-950 text-white">
34      {/* Banner */}
35      <div
36        className="h-80 bg-cover bg-center"
37        style={{ backgroundImage: `url(${mockChannel.banner})` }}
38      />
39
40      {/* Channel Header */}
41      <div className="max-w-6xl mx-auto px-6 -mt-16 relative z-10">
42        <div className="flex items-end gap-6 mb-6">
43          <img
44            src={mockChannel.avatar}
45            alt={mockChannel.displayName}
46            className="w-32 h-32 rounded-full border-4 border-gray-950"
47          />
48
49          <div className="flex-1 pb-4">
50            <h1 className="text-4xl font-bold mb-2">{mockChannel.displayName}</h1>
51            <p className="text-gray-400">{mockChannel.description}</p>
52          </div>
53
54          <div className="flex items-center gap-3 pb-4">
55            <button
56              onClick={() => setIsFollowing(!isFollowing)}
57              className={`px-6 py-2 rounded font-semibold ${
58                isFollowing ? 'bg-gray-700 hover:bg-gray-600' : 'bg-purple-600 hover:bg-purple-700'
59              }`}
60            >
61              {isFollowing ? 'Following' : 'Follow'}
62            </button>
63
64            <button
65              onClick={() => setIsSubscribed(!isSubscribed)}
66              className={`px-6 py-2 rounded font-semibold ${
67                isSubscribed
68                  ? 'bg-purple-900 border border-purple-600 hover:bg-purple-800'
69                  : 'bg-purple-600 hover:bg-purple-700'
70              }`}
71            >
72              {isSubscribed ? 'Subscribed' : 'Subscribe'}
73            </button>
74          </div>
75        </div>
76
77        <div className="flex gap-6 mb-6 text-sm text-gray-400">
78          <div>
79            <span className="font-semibold text-white">
80              {mockChannel.followers.toLocaleString()}
81            </span>{' '}
82            followers
83          </div>
84          <div>
85            <span className="font-semibold text-white">
86              {mockChannel.totalViews.toLocaleString()}
87            </span>{' '}
88            total views
89          </div>
90          {mockChannel.isPartner && (
91            <div className="flex items-center gap-1">
92              <span className="text-purple-500">√</span>
93              <span>Partner</span>
94            </div>
95          )}
96        </div>
97
98        {/* Tabs */}
99        <div className="border-b border-gray-800">
100          <div className="flex gap-8">
101            {[
102              { key: 'home', label: 'Home' },
103              { key: 'videos', label: 'Videos' },
104              { key: 'about', label: 'About' }
105            ].map(tab => (
106              <button
107                key={tab.key}
108                onClick={() => setActiveTab(tab.key as any)}
109                className={`pb-3 font-semibold ${
110                  activeTab === tab.key
111                    ? 'border-b-2 border-purple-500 text-white'
112                    : 'text-gray-400 hover:text-white'
113                }`}
114              >
115                {tab.label}
116              </button>
117            ))}
118          </div>
119        </div>
120      </div>
121
122      {/* Content */}
123      <div className="max-w-6xl mx-auto px-6 py-8">
124        {activeTab === 'home' && mockChannel.isLive && (
125          <div className="text-center py-12">
126            <div className="inline-block px-4 py-2 bg-red-600 text-white font-bold rounded mb-4">
127              LIVE NOW
128            </div>
129            <p className="text-xl">Channel is currently streaming!</p>
130          </div>
131        )}
132
133        {activeTab === 'about' && (
134          <div className="max-w-2xl">
135            <h2 className="text-xl font-bold mb-4">About</h2>
136            <p className="text-gray-300 leading-relaxed">{mockChannel.description}</p>
137          </div>
138        )}
139      </div>
140    </div>
141  );
142}

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. Que sont les clips sur Twitch ?

  2. 2. Quelle est la durée maximale d'un clip dans la fonction createClip ?

Voici 2 des 10 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne les onglets de la page Parcourir :

  • Éditeur de code

    Construis une page de navigation par catégories

  • Tri horizontal

    Ordonne l'appel qui récupère les 2 premiers tags de la catégorie (une ligne) : category.tags.slice(0, 2)

  • Tri vertical

    Ordonne la logique de la fonction createClip (du début jusqu'au retour du clip) :

  • Éditeur de code

    Crée un lecteur de clip

  • Tri vertical

    Ordonne les éléments de CategoryCard affichés du haut vers le bas :

  • Clique dans l'ordre

    Construis l'appel à formatTime pour l'horodatage d'un chapitre : formatTime(chapter.timestamp)

  • Tri vertical

    Ordonne les qualités de stream de la plus haute à la plus basse :

  • Tri vertical

    Ordonne le processus de création d'un clip : du stream en direct jusqu'au clip terminé :

  • Clique dans l'ordre

    Construis l'appel qui change la qualité du stream vers 720p60 : onQualityChange('720p60')

  • Tri vertical

    Ordonne le parcours utilisateur dans la découverte Twitch :

  • Tri horizontal

    Ordonne les onglets de la page de chaîne dans leur ordre d'affichage :

  • Tri vertical

    Ordonne les sections de ClipPlayer affichées du haut vers le bas :

  • Tri horizontal

    Ordonne les éléments de l'affichage de l'onglet Catégories (une ligne JSX) : le mapping des catégories vers CategoryCard

  • Tri horizontal

    Ordonne les boutons d'action dans ClipPlayer (de gauche à droite) :

  • Tri vertical

    Ordonne les éléments de la page Parcourir affichés du haut vers le bas :

  • Tri horizontal

    Ordonne les parties du résultat de formatTime pour un enregistrement de moins d'une heure (par exemple 5:30) :

Articles utiles