Vibe coding with AI course · Module 13: Twitch Clone
Live Stream Player and Chat
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. Main difference between Twitch and YouTube?
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)