Vibe coding with AI course Β· Module 13: Twitch Clone

Categories, Clips, and Discovery

5 min read
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. 1. What are clips in Twitch?

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

Useful articles