Twitch has discovery - browse by category, clips, VODs.
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}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}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}✅ 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! 🚀