Vibe coding with AI course · Module 13: Twitch Clone

Live Stream Player and Chat

6 min read
In this lesson5

Welcome! Time to create Live from the Deck - a live streaming platform like Twitch!

This will be a live streaming platform - video player, live chat, emotes, subscriptions, and 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 ? 'Muted' : volume < 50 ? 'Low' : 'Loud'}
          </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 ? 'Exit fullscreen' : 'Fullscreen'}
          </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="Send a message"
            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>
  );
}

Summary

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

In the next exercise we'll add channel page and subscriptions!

See you soon!

Code for this lesson: App.tsx
'use client';

import { useState } from 'react';

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

const mockStream: Stream = {
  title: 'Pirate Adventures - LIVE!',
  isLive: true,
  viewerCount: 1247,
  quality: ['1080p60', '720p60', '480p', '360p', 'Audio only']
};

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">
          {/* Video Area */}
          <div className="aspect-video bg-gradient-to-br from-purple-900 to-black flex items-center justify-center text-white text-2xl">
            {isPlaying ? 'LIVE STREAM' : '⏸ PAUSED'}
          </div>

          {/* Live Badge */}
          {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">
                LIVE
              </div>
              <div className="px-2 py-1 bg-black/70 text-white text-xs rounded">
                {mockStream.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={() => setIsPlaying(!isPlaying)}
                className="text-white text-2xl hover:scale-110 transition"
              >
                {isPlaying ? '⏸' : '▶'}
              </button>

              {/* Volume */}
              <button onClick={toggleMute} className="text-white text-xl">
                {isMuted || volume === 0 ? 'Muted' : volume < 50 ? 'Low' : 'Loud'}
              </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" />

              {/* Quality */}
              <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>

              {/* Fullscreen */}
              <button className="text-white text-xl hover:scale-110 transition">
                Fullscreen
              </button>
            </div>
          </div>
        </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. Main difference between Twitch and YouTube?

  2. 2. What quality options does a Twitch stream have?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Click in order

    Build the playback state change in togglePlay: setIsPlaying(!isPlaying)

  • Horizontal ordering

    Arrange controls in the stream player:

  • Click in order

    Build the playback state hook: useState(true) for isPlaying

  • Code editor

    Build a stream player with video controls

  • Vertical ordering

    Order the elements rendered in a single chat message (top of the structure first):

  • Code editor

    Create a real-time chat interface

  • Vertical ordering

    Order the steps of the sendMessage function in live chat:

  • Click in order

    Build the mute state update call: setIsMuted(!isMuted)

Useful articles