Cours Vibe coding avec l'IA · Module 13: Clone de Twitch
Catégories, clips et découverte
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. Que sont les clips sur Twitch ?
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) :