Vibe coding with AI course Β· Module 13: Twitch Clone
Categories, Clips, and Discovery
In this lesson4
Twitch has discovery - browse by category, clips, VODs.
Browse Categories
interface BrowseData {
liveChannels: Channel[];
categories: Category[];
clips: Clip[];
vods: VOD[];
}
// Category page
'use client';
export default function BrowsePage() {
const [activeTab, setActiveTab] = useState<'categories' | 'live' | 'clips'>('categories');
return (
<div className="min-h-screen bg-gray-950 text-white p-8">
<h1 className="text-3xl font-bold mb-6">Browse</h1>
{/* Tabs */}
<div className="flex gap-6 mb-8 border-b border-gray-800">
{[
{ key: 'categories', label: 'Categories' },
{ key: 'live', label: 'Live Channels' },
{ key: 'clips', label: 'Clips' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`
pb-3 font-semibold
${activeTab === tab.key
? 'border-b-2 border-purple-500'
: 'text-gray-400 hover:text-white'
}
`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
{activeTab === 'categories' && (
<div className="grid grid-cols-6 gap-4">
{mockCategories.map(category => (
<CategoryCard key={category.id} category={category} />
))}
</div>
)}
{activeTab === 'live' && (
<div className="grid grid-cols-4 gap-4">
{mockLiveChannels.map(channel => (
<LiveChannelCard key={channel.id} channel={channel} />
))}
</div>
)}
{activeTab === 'clips' && (
<div className="grid grid-cols-4 gap-4">
{mockClips.map(clip => (
<ClipCard key={clip.id} clip={clip} />
))}
</div>
)}
</div>
);
}
function CategoryCard({ category }: { category: Category }) {
return (
<div className="cursor-pointer group">
<div className="relative mb-2 overflow-hidden rounded">
<img
src={category.boxArtUrl}
alt={category.name}
className="w-full aspect-[3/4] object-cover group-hover:scale-110 transition"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-2">
<div className="text-xs text-gray-300">
{category.viewerCount.toLocaleString()} viewers
</div>
</div>
</div>
<h3 className="font-semibold">{category.name}</h3>
<div className="flex flex-wrap gap-1 mt-1">
{category.tags.slice(0, 2).map(tag => (
<span key={tag} className="text-xs bg-gray-800 px-2 py-1 rounded">
{tag}
</span>
))}
</div>
</div>
);
}Clips System
interface Clip {
id: string;
streamId: string;
channelId: string;
channel: Channel;
// Content
title: string;
videoUrl: string;
thumbnailUrl: string;
duration: number; // seconds (max 60s)
// Metadata
createdBy: string;
createdAt: Date;
views: number;
category: Category;
// Source
streamTimestamp: number; // position in original stream
}
// Create clip from live stream
function createClip(
streamId: string,
currentTimestamp: number,
duration: number = 30
): Clip {
// Capture video from (currentTimestamp - duration/2) to (currentTimestamp + duration/2)
// Max 60 seconds
return {
id: generateId(),
streamId,
channelId: getCurrentChannel().id,
channel: getCurrentChannel(),
title: `${getCurrentChannel().displayName} - Clip`,
videoUrl: `/clips/${generateId()}.mp4`,
thumbnailUrl: `/clips/${generateId()}-thumb.jpg`,
duration: Math.min(duration, 60),
createdBy: getCurrentUser().id,
createdAt: new Date(),
views: 0,
category: getCurrentStream().category,
streamTimestamp: currentTimestamp
};
}
// Clip player
'use client';
export default function ClipPlayer({ clip }: { clip: Clip }) {
const [isPlaying, setIsPlaying] = useState(false);
return (
<div className="bg-gray-900 rounded overflow-hidden">
{/* Video */}
<div className="relative">
<video
src={clip.videoUrl}
poster={clip.thumbnailUrl}
className="w-full aspect-video"
controls
autoPlay={isPlaying}
/>
</div>
{/* Info */}
<div className="p-4">
<div className="flex items-start gap-3 mb-3">
<img
src={clip.channel.avatar}
alt={clip.channel.displayName}
className="w-10 h-10 rounded-full"
/>
<div className="flex-1">
<h3 className="font-semibold">{clip.title}</h3>
<p className="text-sm text-gray-400">{clip.channel.displayName}</p>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{clip.views.toLocaleString()} views</span>
<span>β’</span>
<span>{new Date(clip.createdAt).toLocaleDateString()}</span>
<span>β’</span>
<span>{clip.category.name}</span>
</div>
{/* Actions */}
<div className="flex gap-2 mt-4">
<button className="flex-1 px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">
Share
</button>
<button className="flex-1 px-4 py-2 bg-gray-800 rounded hover:bg-gray-700">
Watch Full Video
</button>
</div>
</div>
</div>
);
}VODs (Video on Demand)
interface VOD {
id: string;
channelId: string;
channel: Channel;
// Content
title: string;
videoUrl: string;
thumbnailUrl: string;
duration: number; // seconds
// Metadata
streamedAt: Date;
views: number;
category: Category;
// Chapters (optional)
chapters?: Chapter[];
}
interface Chapter {
id: string;
title: string;
timestamp: number; // seconds from start
duration: number;
}
// VOD with chapters
'use client';
export default function VODPlayer({ vod }: { vod: VOD }) {
const [currentTime, setCurrentTime] = useState(0);
const [showChapters, setShowChapters] = useState(false);
const getCurrentChapter = () => {
if (!vod.chapters) return null;
return vod.chapters.find((chapter, i) => {
const nextChapter = vod.chapters![i + 1];
return currentTime >= chapter.timestamp &&
(!nextChapter || currentTime < nextChapter.timestamp);
});
};
const currentChapter = getCurrentChapter();
return (
<div className="bg-gray-900 rounded overflow-hidden">
<video
src={vod.videoUrl}
poster={vod.thumbnailUrl}
className="w-full aspect-video"
controls
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
/>
{/* Chapter indicator */}
{currentChapter && (
<div className="bg-gray-800 px-4 py-2 text-sm">
{currentChapter.title}
</div>
)}
{/* Chapters list */}
{vod.chapters && (
<div>
<button
onClick={() => setShowChapters(!showChapters)}
className="w-full px-4 py-2 bg-gray-800 text-left hover:bg-gray-700"
>
Chapters ({vod.chapters.length})
</button>
{showChapters && (
<div className="max-h-60 overflow-y-auto">
{vod.chapters.map(chapter => (
<button
key={chapter.id}
className={`
w-full px-4 py-2 text-left hover:bg-gray-800
${currentChapter?.id === chapter.id ? 'bg-gray-800' : ''}
`}
>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">
{formatTime(chapter.timestamp)}
</span>
<span className="flex-1">{chapter.title}</span>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}Summary
Browse categories - box art, viewer count, tags Live channels grid - active streams by category Clips system - create clips from streams (max 60s) Clip player - play clip, view info, share VODs - recorded streams VOD chapters - timestamp navigation Category cards - visual discovery Trending clips - most viewed clips
You now have a complete Twitch clone with live streaming, chat, subscriptions, clips, and VODs!
See you soon!
Code for this lesson: App.tsx
'use client';
import { useState } from 'react';
interface Channel {
displayName: string;
description: string;
avatar: string;
banner: string;
followers: number;
totalViews: number;
isLive: boolean;
isPartner: boolean;
}
const mockChannel: Channel = {
displayName: 'Captain Redbeard',
description: 'Streaming pirate adventures and coding tutorials!',
avatar: 'https://i.pravatar.cc/150?img=12',
banner: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1200&h=400&fit=crop',
followers: 15247,
totalViews: 1234567,
isLive: true,
isPartner: true
};
export default function ChannelPage() {
const [isFollowing, setIsFollowing] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [activeTab, setActiveTab] = useState<'home' | 'videos' | 'about'>('home');
return (
<div className="min-h-screen bg-gray-950 text-white">
{/* Banner */}
<div
className="h-80 bg-cover bg-center"
style={{ backgroundImage: `url(${mockChannel.banner})` }}
/>
{/* Channel Header */}
<div className="max-w-6xl mx-auto px-6 -mt-16 relative z-10">
<div className="flex items-end gap-6 mb-6">
<img
src={mockChannel.avatar}
alt={mockChannel.displayName}
className="w-32 h-32 rounded-full border-4 border-gray-950"
/>
<div className="flex-1 pb-4">
<h1 className="text-4xl font-bold mb-2">{mockChannel.displayName}</h1>
<p className="text-gray-400">{mockChannel.description}</p>
</div>
<div className="flex items-center gap-3 pb-4">
<button
onClick={() => setIsFollowing(!isFollowing)}
className={`px-6 py-2 rounded font-semibold ${
isFollowing ? 'bg-gray-700 hover:bg-gray-600' : 'bg-purple-600 hover:bg-purple-700'
}`}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
<button
onClick={() => setIsSubscribed(!isSubscribed)}
className={`px-6 py-2 rounded font-semibold ${
isSubscribed
? 'bg-purple-900 border border-purple-600 hover:bg-purple-800'
: 'bg-purple-600 hover:bg-purple-700'
}`}
>
{isSubscribed ? 'Subscribed' : 'Subscribe'}
</button>
</div>
</div>
<div className="flex gap-6 mb-6 text-sm text-gray-400">
<div>
<span className="font-semibold text-white">
{mockChannel.followers.toLocaleString()}
</span>{' '}
followers
</div>
<div>
<span className="font-semibold text-white">
{mockChannel.totalViews.toLocaleString()}
</span>{' '}
total views
</div>
{mockChannel.isPartner && (
<div className="flex items-center gap-1">
<span className="text-purple-500">β</span>
<span>Partner</span>
</div>
)}
</div>
{/* Tabs */}
<div className="border-b border-gray-800">
<div className="flex gap-8">
{[
{ key: 'home', label: 'Home' },
{ key: 'videos', label: 'Videos' },
{ key: 'about', label: 'About' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`pb-3 font-semibold ${
activeTab === tab.key
? 'border-b-2 border-purple-500 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{/* Content */}
<div className="max-w-6xl mx-auto px-6 py-8">
{activeTab === 'home' && mockChannel.isLive && (
<div className="text-center py-12">
<div className="inline-block px-4 py-2 bg-red-600 text-white font-bold rounded mb-4">
LIVE NOW
</div>
<p className="text-xl">Channel is currently streaming!</p>
</div>
)}
{activeTab === 'about' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold mb-4">About</h2>
<p className="text-gray-300 leading-relaxed">{mockChannel.description}</p>
</div>
)}
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What are clips in Twitch?
2. What is the maximum clip length in the createClip function?
These are 2 of 10 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the tabs on the Browse page:
- Code editor
Build a category browsing page
- Horizontal ordering
Arrange the call that gets the first 2 category tags (one line): category.tags.slice(0, 2)
- Vertical ordering
Order the logic of the createClip function (from start to returning the clip):
- Code editor
Create a clip player
- Vertical ordering
Order the CategoryCard elements rendered from the top:
- Click in order
Build the formatTime call for a chapter timestamp: formatTime(chapter.timestamp)
- Vertical ordering
Order the stream qualities from highest to lowest:
- Vertical ordering
Order the clip creation process: from live stream to finished clip:
- Click in order
Build the stream quality change call to 720p60: onQualityChange('720p60')
- Vertical ordering
Order the user journey through Twitch discovery:
- Horizontal ordering
Arrange the channel page tabs in display order:
- Vertical ordering
Order the ClipPlayer sections rendered from the top:
- Horizontal ordering
Arrange the elements of rendering the Categories tab (one JSX line): mapping over categories to CategoryCard
- Horizontal ordering
Arrange the action buttons in ClipPlayer (left to right):
- Vertical ordering
Order the Browse page elements rendered from the top:
- Horizontal ordering
Arrange the parts of the formatTime result for a recording shorter than an hour (e.g. 5:30):