Kurs vibe coding z AI · Moduł 13: Twitch Clone
Live Stream Player i Chat
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. Główna różnica Twitch vs YouTube?
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)