Kurs vibe coding z AI · Moduł 13: Twitch Clone

Categories, Clips i Discovery

5 min czytania
W tej lekcji4

Twitch ma 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')}`;
}

Podsumowanie

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

Masz teraz kompletny Twitch clone z live streaming, chat, subscriptions, clips i VODs!

Do zobaczenia!

Kod do tej lekcji: 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: 'Kapitan Redbeard',
  description: 'Streamuję pirackie przygody i poradniki programowania!',
  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">
      {/* Baner */}
      <div
        className="h-80 bg-cover bg-center"
        style={{ backgroundImage: `url(${mockChannel.banner})` }}
      />

      {/* Nagłówek kanału */}
      <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 ? 'Obserwujesz' : 'Obserwuj'}
            </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 ? 'Subskrybujesz' : 'Subskrybuj'}
            </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>{' '}
            obserwujących
          </div>
          <div>
            <span className="font-semibold text-white">
              {mockChannel.totalViews.toLocaleString()}
            </span>{' '}
            wyświetleń łącznie
          </div>
          {mockChannel.isPartner && (
            <div className="flex items-center gap-1">
              <span className="text-purple-500">√</span>
              <span>Partner</span>
            </div>
          )}
        </div>

        {/* Zakładki */}
        <div className="border-b border-gray-800">
          <div className="flex gap-8">
            {[
              { key: 'home', label: 'Strona główna' },
              { key: 'videos', label: 'Filmy' },
              { key: 'about', label: 'Informacje' }
            ].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>

      {/* Treść */}
      <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">
              TERAZ NA ŻYWO
            </div>
            <p className="text-xl">Kanał właśnie nadaje na żywo!</p>
          </div>
        )}

        {activeTab === 'about' && (
          <div className="max-w-2xl">
            <h2 className="text-xl font-bold mb-4">Informacje</h2>
            <p className="text-gray-300 leading-relaxed">{mockChannel.description}</p>
          </div>
        )}
      </div>
    </div>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym są clips w Twitch?

  2. 2. Jaka jest maksymalna długość clipa w funkcji createClip?

To 2 z 10 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż zakładki na stronie Browse:

  • Edytor kodu

    Zbuduj stronę przeglądania kategorii

  • Układanie w poziomie

    Ułóż wywołanie pobrania pierwszych 2 tagów kategorii (jedna linia): category.tags.slice(0, 2)

  • Układanie w pionie

    Uporządkuj logikę funkcji createClip (od początku do zwrócenia clipa):

  • Edytor kodu

    Stwórz odtwarzacz clipów

  • Układanie w pionie

    Uporządkuj elementy CategoryCard renderowane od góry:

  • Klikanie w kolejności

    Zbuduj wywołanie formatTime dla timestampu chaptera: formatTime(chapter.timestamp)

  • Układanie w pionie

    Uporządkuj jakości streamu od najwyższej do najniższej:

  • Układanie w pionie

    Uporządkuj proces tworzenia clipa: od live streamu do gotowego clipa:

  • Klikanie w kolejności

    Zbuduj wywołanie zmiany jakości streamu na 720p60: onQualityChange('720p60')

  • Układanie w pionie

    Uporządkuj ścieżkę użytkownika w discovery Twitcha:

  • Układanie w poziomie

    Ułóż zakładki channel page w kolejności wyświetlania:

  • Układanie w pionie

    Uporządkuj sekcje ClipPlayer renderowane od góry:

  • Układanie w poziomie

    Ułóż elementy renderowania zakładki Categories (jedna linia JSX): mapowanie po kategoriach do CategoryCard

  • Układanie w poziomie

    Ułóż przyciski akcji w ClipPlayer (lewo do prawo):

  • Układanie w pionie

    Uporządkuj elementy strony Browse renderowane od góry:

  • Układanie w poziomie

    Ułóż fragmenty wyniku formatTime dla nagrania krótszego niż godzina (np. 5:30):

Przydatne artykuły