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

Live Stream Player i Chat

6 min czytania
W tej lekcji5

Witaj! Czas stworzyć Live from the Deck - platformę live streamingu jak Twitch!

To będzie live streaming platform - video player, live chat, emotes, subscriptions i clips.

Twitch vs YouTube/TikTok

YouTube:

  • Pre-recorded videos (upload → watch)
  • VOD (Video on Demand)
  • Long-form content

TikTok:

  • Short vertical videos
  • Algorithm-driven feed
  • Not primarily live

Twitch:

  • LIVE streaming (real-time broadcast)
  • Interactive chat with emotes
  • Subscriptions and bits (donations)
  • Categories and tags
  • Clips (highlight moments from streams)
  • VODs (recordings of past streams)
  • Raids (send viewers to another channel)
  • Follower/Subscriber system

Stream Structure

interface Stream {
  id: string;
  channelId: string;
  channel: Channel;

  // Stream info
  title: string;
  category: Category;
  tags: string[];

  // Video
  videoUrl: string; // HLS or RTMP stream URL
  thumbnailUrl: string;
  quality: StreamQuality[];

  // Metadata
  isLive: boolean;
  viewerCount: number;
  startedAt: Date;
  duration: number; // seconds (if VOD)

  // Chat
  chatEnabled: boolean;
  slowMode: boolean; // limit message frequency
  subscribersOnly: boolean;
  emotesOnly: boolean;
}

interface Channel {
  id: string;
  username: string;
  displayName: string;
  avatar: string;
  banner?: string;
  description: string;

  // Stats
  followers: number;
  subscribers: number;
  totalViews: number;

  // Monetization
  isPartner: boolean;
  isAffiliate: boolean;

  // Current stream
  isLive: boolean;
  currentStream?: Stream;
}

interface StreamQuality {
  label: string; // "1080p60", "720p60", "480p", "360p", "160p", "Audio only"
  resolution: { width: number; height: number };
  bitrate: number;
  fps: number;
}

interface Category {
  id: string;
  name: string;
  boxArtUrl: string;
  viewerCount: number;
  tags: string[];
}

Stream Player

'use client';

import { useState, useRef, useEffect } from 'react';

interface StreamPlayerProps {
  stream: Stream;
  onQualityChange: (quality: string) => void;
}

export default function StreamPlayer({ stream, onQualityChange }: StreamPlayerProps) {
  const [isPlaying, setIsPlaying] = useState(true);
  const [isMuted, setIsMuted] = useState(false);
  const [volume, setVolume] = useState(50);
  const [selectedQuality, setSelectedQuality] = useState('1080p60');
  const [isFullscreen, setIsFullscreen] = useState(false);
  const videoRef = useRef<HTMLVideoElement>(null);

  const togglePlay = () => {
    if (videoRef.current) {
      if (isPlaying) {
        videoRef.current.pause();
      } else {
        videoRef.current.play();
      }
      setIsPlaying(!isPlaying);
    }
  };

  const toggleMute = () => {
    if (videoRef.current) {
      videoRef.current.muted = !isMuted;
      setIsMuted(!isMuted);
    }
  };

  const handleVolumeChange = (newVolume: number) => {
    if (videoRef.current) {
      videoRef.current.volume = newVolume / 100;
      setVolume(newVolume);
      setIsMuted(newVolume === 0);
    }
  };

  const changeQuality = (quality: string) => {
    setSelectedQuality(quality);
    onQualityChange(quality);
  };

  const toggleFullscreen = () => {
    if (!document.fullscreenElement) {
      videoRef.current?.requestFullscreen();
      setIsFullscreen(true);
    } else {
      document.exitFullscreen();
      setIsFullscreen(false);
    }
  };

  return (
    <div className="relative bg-black group">
      {/* Video */}
      <video
        ref={videoRef}
        src={stream.videoUrl}
        className="w-full aspect-video"
        autoPlay
        playsInline
      />

      {/* Live Badge */}
      {stream.isLive && (
        <div className="absolute top-4 left-4 flex items-center gap-2">
          <div className="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded">
            LIVE
          </div>
          <div className="px-2 py-1 bg-black/70 text-white text-xs rounded">
            {stream.viewerCount.toLocaleString()} viewers
          </div>
        </div>
      )}

      {/* Controls Overlay */}
      <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
        <div className="flex items-center gap-4">
          {/* Play/Pause */}
          <button
            onClick={togglePlay}
            className="text-white text-2xl hover:scale-110 transition"
          >
            {isPlaying ? '⏸' : '▶'}
          </button>

          {/* Volume */}
          <button
            onClick={toggleMute}
            className="text-white text-xl hover:scale-110 transition"
          >
            {isMuted || volume === 0 ? 'Wyciszony' : volume < 50 ? 'Cicho' : 'Głośno'}
          </button>

          <input
            type="range"
            min={0}
            max={100}
            value={isMuted ? 0 : volume}
            onChange={(e) => handleVolumeChange(parseInt(e.target.value))}
            className="w-20 h-1 bg-gray-600 rounded-lg"
          />

          <div className="flex-1" />

          {/* Quality Selector */}
          <div className="relative group/quality">
            <button className="px-3 py-1 bg-gray-800/80 text-white text-sm rounded hover:bg-gray-800/100">
              {selectedQuality}
            </button>
            <div className="absolute bottom-full right-0 mb-2 bg-gray-900 rounded overflow-hidden opacity-0 group-hover/quality:opacity-100 transition-opacity">
              {stream.quality.map(q => (
                <button
                  key={q.label}
                  onClick={() => changeQuality(q.label)}
                  className={`
                    block w-full text-left px-4 py-2 text-white text-sm hover:bg-purple-600
                    ${selectedQuality === q.label ? 'bg-purple-700' : ''}
                  `}
                >
                  {q.label}
                </button>
              ))}
            </div>
          </div>

          {/* Fullscreen */}
          <button
            onClick={toggleFullscreen}
            className="text-white text-xl hover:scale-110 transition"
          >
            {isFullscreen ? 'Zamknij pełny ekran' : 'Pełny ekran'}
          </button>
        </div>
      </div>
    </div>
  );
}

Live Chat

interface ChatMessage {
  id: string;
  userId: string;
  username: string;
  displayName: string;
  userBadges: Badge[];
  message: string;
  emotes: Emote[];
  timestamp: Date;
  isHighlighted: boolean; // paid highlight
  isPinned: boolean;
}

interface Badge {
  type: 'broadcaster' | 'moderator' | 'subscriber' | 'vip' | 'partner';
  label: string;
  imageUrl: string;
}

interface Emote {
  id: string;
  name: string; // e.g., "Kappa", "PogChamp"
  imageUrl: string;
  startIndex: number;
  endIndex: number;
}

'use client';

export default function LiveChat({ streamId }: { streamId: string }) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [showEmotePicker, setShowEmotePicker] = useState(false);

  const sendMessage = () => {
    if (!inputValue.trim()) return;

    const newMessage: ChatMessage = {
      id: Date.now().toString(),
      userId: 'user123',
      username: 'currentUser',
      displayName: 'Current User',
      userBadges: [],
      message: inputValue,
      emotes: [],
      timestamp: new Date(),
      isHighlighted: false,
      isPinned: false
    };

    setMessages([...messages, newMessage]);
    setInputValue('');
  };

  const parseMessageWithEmotes = (msg: ChatMessage) => {
    if (msg.emotes.length === 0) return msg.message;

    const parts: Array<{ text?: string; emote?: Emote }> = [];
    let lastIndex = 0;

    msg.emotes
      .sort((a, b) => a.startIndex - b.startIndex)
      .forEach(emote => {
        if (emote.startIndex > lastIndex) {
          parts.push({
            text: msg.message.slice(lastIndex, emote.startIndex)
          });
        }

        parts.push({ emote });
        lastIndex = emote.endIndex;
      });

    if (lastIndex < msg.message.length) {
      parts.push({ text: msg.message.slice(lastIndex) });
    }

    return parts.map((part, i) =>
      part.emote ? (
        <img
          key={i}
          src={part.emote.imageUrl}
          alt={part.emote.name}
          className="inline h-6 align-middle"
        />
      ) : (
        <span key={i}>{part.text}</span>
      )
    );
  };

  return (
    <div className="h-full bg-gray-900 flex flex-col">
      {/* Chat Header */}
      <div className="p-3 border-b border-gray-800">
        <h3 className="text-white font-semibold">STREAM CHAT</h3>
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-3 space-y-2">
        {messages.map(msg => (
          <div
            key={msg.id}
            className={`
              text-sm
              ${msg.isHighlighted ? 'bg-purple-900/30 p-2 rounded' : ''}
            `}
          >
            {/* Badges */}
            {msg.userBadges.map(badge => (
              <img
                key={badge.type}
                src={badge.imageUrl}
                alt={badge.label}
                className="inline h-4 mr-1"
                title={badge.label}
              />
            ))}

            {/* Username */}
            <span className="font-semibold text-purple-400">
              {msg.displayName}:
            </span>

            {/* Message */}
            <span className="text-white ml-1">
              {parseMessageWithEmotes(msg)}
            </span>
          </div>
        ))}
      </div>

      {/* Input */}
      <div className="p-3 border-t border-gray-800">
        <div className="flex gap-2">
          <button
            onClick={() => setShowEmotePicker(!showEmotePicker)}
            className="p-2 text-gray-400 hover:text-white"
          >
            :)
          </button>

          <input
            type="text"
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
            placeholder="Wyślij wiadomość"
            className="flex-1 bg-gray-800 text-white px-3 py-2 rounded outline-none focus:ring-2 focus:ring-purple-500"
          />

          <button
            onClick={sendMessage}
            disabled={!inputValue.trim()}
            className="px-4 py-2 bg-purple-600 text-white rounded font-semibold hover:bg-purple-700 disabled:bg-gray-700 disabled:cursor-not-allowed"
          >
            Chat
          </button>
        </div>
      </div>
    </div>
  );
}

Podsumowanie

Stream player - video playback, play/pause, volume Quality selector - 1080p60, 720p60, 480p, etc. Live badge - LIVE indicator + viewer count Fullscreen - toggle fullscreen mode Live chat - real-time messages Chat emotes - parse and display emotes User badges - broadcaster, mod, subscriber, VIP Highlighted messages - paid highlights

W następnym ćwiczeniu dodamy channel page i subscriptions!

Do zobaczenia!

Kod do tej lekcji: App.tsx
'use client';

import { useState } from 'react';

interface Stream {
  title: string;
  isLive: boolean;
  viewerCount: number;
  quality: string[];
}

const mockStream: Stream = {
  title: 'Pirackie przygody - NA ŻYWO!',
  isLive: true,
  viewerCount: 1247,
  quality: ['1080p60', '720p60', '480p', '360p', 'Tylko dźwięk']
};

export default function StreamPlayer() {
  const [isPlaying, setIsPlaying] = useState(true);
  const [isMuted, setIsMuted] = useState(false);
  const [volume, setVolume] = useState(50);
  const [selectedQuality, setSelectedQuality] = useState('1080p60');
  const [showQuality, setShowQuality] = useState(false);

  const toggleMute = () => setIsMuted(!isMuted);

  return (
    <div className="min-h-screen bg-black flex items-center justify-center">
      <div className="w-full max-w-4xl">
        <div className="relative bg-gray-900 group">
          {/* Obszar wideo */}
          <div className="aspect-video bg-gradient-to-br from-purple-900 to-black flex items-center justify-center text-white text-2xl">
            {isPlaying ? 'TRANSMISJA NA ŻYWO' : 'WSTRZYMANO'}
          </div>

          {/* Plakietka na żywo */}
          {mockStream.isLive && (
            <div className="absolute top-4 left-4 flex items-center gap-2">
              <div className="px-2 py-1 bg-red-600 text-white text-xs font-bold rounded">
                NA ŻYWO
              </div>
              <div className="px-2 py-1 bg-black/70 text-white text-xs rounded">
                {mockStream.viewerCount.toLocaleString()} widzów
              </div>
            </div>
          )}

          {/* Nakładka sterowania */}
          <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
            <div className="flex items-center gap-4">
              {/* Odtwarzanie i pauza */}
              <button
                onClick={() => setIsPlaying(!isPlaying)}
                className="text-white text-2xl hover:scale-110 transition"
              >
                {isPlaying ? '⏸' : '▶'}
              </button>

              {/* Głośność */}
              <button onClick={toggleMute} className="text-white text-xl">
                {isMuted || volume === 0 ? 'Wyciszony' : volume < 50 ? 'Cicho' : 'Głośno'}
              </button>

              <input
                type="range"
                min={0}
                max={100}
                value={isMuted ? 0 : volume}
                onChange={(e) => setVolume(parseInt(e.target.value))}
                className="w-20"
              />

              <div className="flex-1" />

              {/* Jakość */}
              <div className="relative">
                <button
                  onClick={() => setShowQuality(!showQuality)}
                  className="px-3 py-1 bg-gray-800/80 text-white text-sm rounded hover:bg-gray-800/100"
                >
                  {selectedQuality}
                </button>
                {showQuality && (
                  <div className="absolute bottom-full right-0 mb-2 bg-gray-900 rounded overflow-hidden">
                    {mockStream.quality.map(q => (
                      <button
                        key={q}
                        onClick={() => {
                          setSelectedQuality(q);
                          setShowQuality(false);
                        }}
                        className={`block w-full text-left px-4 py-2 text-white text-sm hover:bg-purple-600 ${selectedQuality === q ? 'bg-purple-700' : ''}`}
                      >
                        {q}
                      </button>
                    ))}
                  </div>
                )}
              </div>

              {/* Pełny ekran */}
              <button className="text-white text-xl hover:scale-110 transition">
                Pełny ekran
              </button>
            </div>
          </div>
        </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. Główna różnica Twitch vs YouTube?

  2. 2. Jakie quality options ma Twitch stream?

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

Zadania praktyczne w grze

  • Klikanie w kolejności

    Zbuduj zmianę stanu odtwarzania w togglePlay: setIsPlaying(!isPlaying)

  • Układanie w poziomie

    Ułóż controls w stream player:

  • Klikanie w kolejności

    Zbuduj hook stanu odtwarzania: useState(true) dla isPlaying

  • Edytor kodu

    Zbuduj stream player z kontrolkami video

  • Układanie w pionie

    Uporządkuj elementy renderowane w jednej wiadomości czatu (od góry struktury):

  • Edytor kodu

    Stwórz real-time chat interface

  • Układanie w pionie

    Uporządkuj kroki funkcji sendMessage w live chat:

  • Klikanie w kolejności

    Zbuduj wywołanie zmiany stanu wyciszenia: setIsMuted(!isMuted)

Przydatne artykuły