gaming-hub/web/src/plugins/watch-together/WatchTogetherTab.tsx

860 lines
31 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef, useCallback } from 'react';
import './watch-together.css';
// ── Types ──
interface RoomInfo {
id: string;
name: string;
hostName: string;
memberCount: number;
hasPassword: boolean;
playing: boolean;
}
interface RoomState {
id: string;
name: string;
hostId: string;
members: Array<{ id: string; name: string }>;
currentVideo: { url: string; title: string } | null;
playing: boolean;
currentTime: number;
queue: Array<{ url: string; title: string; addedBy: string }>;
history: Array<{ url: string; title: string; addedBy: string; playedAt: string }>;
}
interface JoinModal {
roomId: string;
roomName: string;
password: string;
error: string | null;
}
// ── Helpers ──
function formatTime(s: number): string {
const sec = Math.floor(s);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const ss = sec % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
return `${m}:${String(ss).padStart(2, '0')}`;
}
function parseVideoUrl(url: string): { type: 'youtube'; videoId: string } | { type: 'direct'; url: string } | null {
const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
if (ytMatch) return { type: 'youtube', videoId: ytMatch[1] };
if (/\.(mp4|webm|ogg)(\?|$)/i.test(url) || url.startsWith('http')) return { type: 'direct', url };
return null;
}
declare global {
interface Window {
YT: any;
onYouTubeIframeAPIReady: (() => void) | undefined;
}
}
// ── Component ──
export default function WatchTogetherTab({ data }: { data: any }) {
// ── State ──
const [rooms, setRooms] = useState<RoomInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('wt_name') || '');
const [roomName, setRoomName] = useState('');
const [roomPassword, setRoomPassword] = useState('');
const [currentRoom, setCurrentRoom] = useState<RoomState | null>(null);
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
const [error, setError] = useState<string | null>(null);
const [queueUrl, setQueueUrl] = useState('');
const [volume, setVolume] = useState(() => {
const v = localStorage.getItem('wt_volume');
return v ? parseFloat(v) : 1;
});
const [isFullscreen, setIsFullscreen] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [playerError, setPlayerError] = useState<string | null>(null);
const [addingToQueue, setAddingToQueue] = useState(false);
const [showHistory, setShowHistory] = useState(false);
// ── Refs ──
const wsRef = useRef<WebSocket | null>(null);
const clientIdRef = useRef<string>('');
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(1000);
const currentRoomRef = useRef<RoomState | null>(null);
const roomContainerRef = useRef<HTMLDivElement | null>(null);
// Player refs
const ytPlayerRef = useRef<any>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const playerContainerRef = useRef<HTMLDivElement | null>(null);
const currentVideoTypeRef = useRef<'youtube' | 'direct' | null>(null);
const ytReadyRef = useRef(false);
const seekingRef = useRef(false);
const timeUpdateRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Mirror state to refs
useEffect(() => { currentRoomRef.current = currentRoom; }, [currentRoom]);
const isHost = currentRoom != null && clientIdRef.current === currentRoom.hostId;
// ── SSE data ──
useEffect(() => {
if (data?.rooms) {
setRooms(data.rooms);
}
}, [data]);
// ── Save name ──
useEffect(() => {
if (userName) localStorage.setItem('wt_name', userName);
}, [userName]);
// ── Save volume ──
useEffect(() => {
localStorage.setItem('wt_volume', String(volume));
if (ytPlayerRef.current && typeof ytPlayerRef.current.setVolume === 'function') {
ytPlayerRef.current.setVolume(volume * 100);
}
if (videoRef.current) {
videoRef.current.volume = volume;
}
}, [volume]);
// ── YouTube IFrame API ──
useEffect(() => {
if (window.YT) { ytReadyRef.current = true; return; }
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
const prev = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
ytReadyRef.current = true;
if (prev) prev();
};
}, []);
// ── WS send ──
const wsSend = useCallback((d: Record<string, any>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(d));
}
}, []);
// ── Get current time from active player ──
const getCurrentTime = useCallback((): number | null => {
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current && typeof ytPlayerRef.current.getCurrentTime === 'function') {
return ytPlayerRef.current.getCurrentTime();
}
if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
return videoRef.current.currentTime;
}
return null;
}, []);
// ── Get duration from active player ──
const getDuration = useCallback((): number => {
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current && typeof ytPlayerRef.current.getDuration === 'function') {
return ytPlayerRef.current.getDuration() || 0;
}
if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
return videoRef.current.duration || 0;
}
return 0;
}, []);
// ── Destroy player ──
const destroyPlayer = useCallback(() => {
if (ytPlayerRef.current) {
try { ytPlayerRef.current.destroy(); } catch {}
ytPlayerRef.current = null;
}
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.removeAttribute('src');
videoRef.current.load();
}
currentVideoTypeRef.current = null;
if (timeUpdateRef.current) {
clearInterval(timeUpdateRef.current);
timeUpdateRef.current = null;
}
}, []);
// ── Load video ──
const loadVideo = useCallback((url: string) => {
destroyPlayer();
setPlayerError(null);
const parsed = parseVideoUrl(url);
if (!parsed) return;
if (parsed.type === 'youtube') {
currentVideoTypeRef.current = 'youtube';
if (!ytReadyRef.current || !playerContainerRef.current) return;
// Create a fresh div for YT player
const container = playerContainerRef.current;
const ytDiv = document.createElement('div');
ytDiv.id = 'wt-yt-player-' + Date.now();
container.innerHTML = '';
container.appendChild(ytDiv);
ytPlayerRef.current = new window.YT.Player(ytDiv.id, {
videoId: parsed.videoId,
playerVars: { autoplay: 1, controls: 0, modestbranding: 1, rel: 0 },
events: {
onReady: (ev: any) => {
ev.target.setVolume(volume * 100);
setDuration(ev.target.getDuration() || 0);
// Time update interval
timeUpdateRef.current = setInterval(() => {
if (ytPlayerRef.current && typeof ytPlayerRef.current.getCurrentTime === 'function') {
setCurrentTime(ytPlayerRef.current.getCurrentTime());
setDuration(ytPlayerRef.current.getDuration() || 0);
}
}, 500);
},
onStateChange: (ev: any) => {
if (ev.data === window.YT.PlayerState.ENDED) {
const room = currentRoomRef.current;
if (room && clientIdRef.current === room.hostId) {
wsSend({ type: 'skip' });
}
}
},
onError: (ev: any) => {
const code = ev.data;
// 101 or 150 = embedding disabled by video owner
// 100 = video not found
// 5 = HTML5 player error
if (code === 101 || code === 150) {
setPlayerError('Embedding deaktiviert \u2014 Video kann nur auf YouTube angesehen werden.');
} else if (code === 100) {
setPlayerError('Video nicht gefunden oder entfernt.');
} else {
setPlayerError('Wiedergabefehler \u2014 Video wird \u00FCbersprungen.');
}
// Auto-skip after 3 seconds if host
setTimeout(() => {
const room = currentRoomRef.current;
if (room && clientIdRef.current === room.hostId) {
wsSend({ type: 'skip' });
}
}, 3000);
},
},
});
} else {
currentVideoTypeRef.current = 'direct';
if (videoRef.current) {
videoRef.current.src = parsed.url;
videoRef.current.volume = volume;
videoRef.current.play().catch(() => {});
}
}
}, [destroyPlayer, volume, wsSend]);
// ── HTML5 video ended handler ──
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onEnded = () => {
const room = currentRoomRef.current;
if (room && clientIdRef.current === room.hostId) {
wsSend({ type: 'skip' });
}
};
const onTimeUpdate = () => {
setCurrentTime(video.currentTime);
setDuration(video.duration || 0);
};
video.addEventListener('ended', onEnded);
video.addEventListener('timeupdate', onTimeUpdate);
return () => {
video.removeEventListener('ended', onEnded);
video.removeEventListener('timeupdate', onTimeUpdate);
};
}, [wsSend]);
// ── WS message handler ──
const handleWsMessageRef = useRef<(msg: any) => void>(() => {});
handleWsMessageRef.current = (msg: any) => {
switch (msg.type) {
case 'welcome':
clientIdRef.current = msg.clientId;
if (msg.rooms) setRooms(msg.rooms);
break;
case 'room_created': {
const r = msg.room;
setCurrentRoom({
id: r.id,
name: r.name,
hostId: r.hostId,
members: r.members || [],
currentVideo: r.currentVideo || null,
playing: r.playing || false,
currentTime: r.currentTime || 0,
queue: r.queue || [],
history: r.history || [],
});
break;
}
case 'room_joined': {
const r = msg.room;
setCurrentRoom({
id: r.id,
name: r.name,
hostId: r.hostId,
members: r.members || [],
currentVideo: r.currentVideo || null,
playing: r.playing || false,
currentTime: r.currentTime || 0,
queue: r.queue || [],
history: r.history || [],
});
if (r.currentVideo?.url) {
setTimeout(() => loadVideo(r.currentVideo.url), 100);
}
break;
}
case 'playback_state': {
const room = currentRoomRef.current;
if (!room) break;
const newVideo = msg.currentVideo;
const prevUrl = room.currentVideo?.url;
// 1. Video URL changed → load new video
if (newVideo?.url && newVideo.url !== prevUrl) {
loadVideo(newVideo.url);
} else if (!newVideo && prevUrl) {
destroyPlayer();
}
// 2. Playing mismatch → play or pause
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current) {
const playerState = ytPlayerRef.current.getPlayerState?.();
if (msg.playing && playerState !== window.YT?.PlayerState?.PLAYING) {
ytPlayerRef.current.playVideo?.();
} else if (!msg.playing && playerState === window.YT?.PlayerState?.PLAYING) {
ytPlayerRef.current.pauseVideo?.();
}
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
if (msg.playing && videoRef.current.paused) {
videoRef.current.play().catch(() => {});
} else if (!msg.playing && !videoRef.current.paused) {
videoRef.current.pause();
}
}
// 3. Drift correction: if |localTime - serverTime| > 2 → seek
if (msg.currentTime !== undefined && !seekingRef.current) {
const localTime = getCurrentTime();
if (localTime !== null && Math.abs(localTime - msg.currentTime) > 2) {
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current) {
ytPlayerRef.current.seekTo?.(msg.currentTime, true);
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
videoRef.current.currentTime = msg.currentTime;
}
}
}
setCurrentRoom(prev => prev ? {
...prev,
currentVideo: newVideo || null,
playing: msg.playing,
currentTime: msg.currentTime ?? prev.currentTime,
history: msg.history ?? prev.history,
} : prev);
break;
}
case 'queue_updated':
setCurrentRoom(prev => prev ? { ...prev, queue: msg.queue } : prev);
break;
case 'members_updated':
setCurrentRoom(prev => prev ? { ...prev, members: msg.members, hostId: msg.hostId } : prev);
break;
case 'error':
if (msg.code === 'WRONG_PASSWORD') {
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
} else {
setError(msg.message);
}
break;
}
};
// ── WebSocket connect ──
const connectWs = useCallback(() => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/watch-together`);
wsRef.current = ws;
ws.onopen = () => { reconnectDelayRef.current = 1000; };
ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { return; }
handleWsMessageRef.current(msg);
};
ws.onclose = () => {
if (wsRef.current === ws) {
wsRef.current = null;
}
if (currentRoomRef.current) {
reconnectTimerRef.current = setTimeout(() => {
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
connectWs();
}, reconnectDelayRef.current);
}
};
ws.onerror = () => { ws.close(); };
}, []);
// ── Create room ──
const createRoom = useCallback(() => {
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
if (!roomName.trim()) { setError('Bitte gib einen Raumnamen ein.'); return; }
setError(null);
connectWs();
const startTime = Date.now();
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({
type: 'create_room',
name: roomName.trim(),
userName: userName.trim(),
password: roomPassword.trim() || undefined,
});
} else if (Date.now() - startTime > 10000) {
setError('Verbindung zum Server fehlgeschlagen.');
} else {
setTimeout(waitForWs, 100);
}
};
waitForWs();
}, [userName, roomName, roomPassword, connectWs, wsSend]);
// ── Join room ──
const joinRoom = useCallback((roomId: string, password?: string) => {
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
setError(null);
connectWs();
const startTime = Date.now();
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({
type: 'join_room',
userName: userName.trim(),
roomId,
password: password?.trim() || undefined,
});
} else if (Date.now() - startTime > 10000) {
setError('Verbindung zum Server fehlgeschlagen.');
} else {
setTimeout(waitForWs, 100);
}
};
waitForWs();
}, [userName, connectWs, wsSend]);
// ── Leave room ──
const leaveRoom = useCallback(() => {
wsSend({ type: 'leave_room' });
destroyPlayer();
setCurrentRoom(null);
setQueueUrl('');
setDuration(0);
setCurrentTime(0);
}, [wsSend, destroyPlayer]);
// ── Add to queue ──
const addToQueue = useCallback(async () => {
const url = queueUrl.trim();
if (!url) return;
setQueueUrl('');
setAddingToQueue(true);
let title = '';
try {
const resp = await fetch(`/api/watch-together/video-info?url=${encodeURIComponent(url)}`);
if (resp.ok) {
const data = await resp.json();
title = data.title || '';
}
} catch { /* use URL as fallback */ }
wsSend({ type: 'add_to_queue', url, title: title || undefined });
setAddingToQueue(false);
}, [queueUrl, wsSend]);
// ── Remove from queue ──
const removeFromQueue = useCallback((index: number) => {
wsSend({ type: 'remove_from_queue', index });
}, [wsSend]);
// ── Playback controls (host only) ──
const togglePlay = useCallback(() => {
const room = currentRoomRef.current;
if (!room) return;
wsSend({ type: room.playing ? 'pause' : 'resume' });
}, [wsSend]);
const skip = useCallback(() => {
wsSend({ type: 'skip' });
}, [wsSend]);
const seek = useCallback((time: number) => {
seekingRef.current = true;
wsSend({ type: 'seek', time });
// Seek locally immediately
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current) {
ytPlayerRef.current.seekTo?.(time, true);
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
videoRef.current.currentTime = time;
}
setCurrentTime(time);
setTimeout(() => { seekingRef.current = false; }, 1000);
}, [wsSend]);
// ── Host time reporting ──
useEffect(() => {
if (!isHost || !currentRoom?.playing) return;
const iv = setInterval(() => {
const time = getCurrentTime();
if (time !== null) wsSend({ type: 'report_time', time });
}, 2000);
return () => clearInterval(iv);
}, [isHost, currentRoom?.playing, wsSend, getCurrentTime]);
// ── Fullscreen ──
const toggleFullscreen = useCallback(() => {
const el = roomContainerRef.current;
if (!el) return;
if (!document.fullscreenElement) el.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {});
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
// ── beforeunload + pagehide ──
useEffect(() => {
const beforeUnload = (e: BeforeUnloadEvent) => {
if (currentRoomRef.current) e.preventDefault();
};
const pageHide = () => {
if (clientIdRef.current) {
navigator.sendBeacon('/api/watch-together/disconnect', JSON.stringify({ clientId: clientIdRef.current }));
}
};
window.addEventListener('beforeunload', beforeUnload);
window.addEventListener('pagehide', pageHide);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
window.removeEventListener('pagehide', pageHide);
};
}, []);
// ── Cleanup on unmount ──
useEffect(() => {
return () => {
destroyPlayer();
if (wsRef.current) wsRef.current.close();
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
};
}, [destroyPlayer]);
// ── Join modal submit ──
const submitJoinModal = useCallback(() => {
if (!joinModal) return;
if (!joinModal.password.trim()) {
setJoinModal(prev => prev ? { ...prev, error: 'Passwort eingeben.' } : prev);
return;
}
const { roomId, password } = joinModal;
setJoinModal(null);
joinRoom(roomId, password);
}, [joinModal, joinRoom]);
// ── Tile click ──
const handleTileClick = useCallback((room: RoomInfo) => {
if (room.hasPassword) {
setJoinModal({ roomId: room.id, roomName: room.name, password: '', error: null });
} else {
joinRoom(room.id);
}
}, [joinRoom]);
// ── Render: Room View ──
if (currentRoom) {
const hostMember = currentRoom.members.find(m => m.id === currentRoom.hostId);
return (
<div className="wt-room-overlay" ref={roomContainerRef}>
<div className="wt-room-header">
<div className="wt-room-header-left">
<span className="wt-room-name">{currentRoom.name}</span>
<span className="wt-room-members">{currentRoom.members.length} Mitglieder</span>
{hostMember && <span className="wt-host-badge">Host: {hostMember.name}</span>}
</div>
<div className="wt-room-header-right">
<button className="wt-fullscreen-btn" onClick={toggleFullscreen} title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}>
{isFullscreen ? '\u2716' : '\u26F6'}
</button>
<button className="wt-leave-btn" onClick={leaveRoom}>Verlassen</button>
</div>
</div>
<div className="wt-room-body">
<div className="wt-player-section">
<div className="wt-player-wrap">
<div ref={playerContainerRef} className="wt-yt-container" style={currentVideoTypeRef.current === 'youtube' ? {} : { display: 'none' }} />
<video
ref={videoRef}
className="wt-video-element"
style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }}
playsInline
/>
{playerError && (
<div className="wt-player-error">
<div className="wt-error-icon">{'\u26A0\uFE0F'}</div>
<p>{playerError}</p>
{currentRoom.currentVideo?.url && (
<a
className="wt-yt-link"
href={currentRoom.currentVideo.url}
target="_blank"
rel="noopener noreferrer"
>
Auf YouTube &ouml;ffnen &#8599;
</a>
)}
<p className="wt-skip-info">Wird in 3 Sekunden &uuml;bersprungen...</p>
</div>
)}
{!currentRoom.currentVideo && (
<div className="wt-player-placeholder">
<div className="wt-placeholder-icon">{'\uD83C\uDFAC'}</div>
<p>Fuege ein Video zur Warteschlange hinzu</p>
</div>
)}
</div>
<div className="wt-controls">
{isHost ? (
<>
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
{currentRoom.playing ? '\u23F8' : '\u25B6'}
</button>
<button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video">
{'\u23ED'} Weiter
</button>
<input
className="wt-seek"
type="range"
min={0}
max={duration || 0}
step={0.5}
value={currentTime}
onChange={e => seek(parseFloat(e.target.value))}
disabled={!currentRoom.currentVideo}
/>
</>
) : (
<>
<span className="wt-ctrl-status">{currentRoom.playing ? '\u25B6' : '\u23F8'}</span>
<div className="wt-seek-readonly">
<div className="wt-seek-progress" style={{ width: duration > 0 ? `${(currentTime / duration) * 100}%` : '0%' }} />
</div>
</>
)}
<span className="wt-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<div className="wt-volume">
<span className="wt-volume-icon">{volume === 0 ? '\uD83D\uDD07' : volume < 0.5 ? '\uD83D\uDD09' : '\uD83D\uDD0A'}</span>
<input
className="wt-volume-slider"
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={e => setVolume(parseFloat(e.target.value))}
/>
</div>
</div>
</div>
<div className="wt-queue-panel">
<div className="wt-queue-header">Warteschlange ({currentRoom.queue.length})</div>
<div className="wt-queue-list">
{currentRoom.queue.length === 0 ? (
<div className="wt-queue-empty">Keine Videos in der Warteschlange</div>
) : (
currentRoom.queue.map((item, i) => (
<div
key={i}
className={`wt-queue-item${currentRoom.currentVideo?.url === item.url ? ' playing' : ''}${isHost ? ' clickable' : ''}`}
onClick={() => isHost && wsSend({ type: 'play_video', index: i })}
title={isHost ? 'Klicken zum Abspielen' : undefined}
>
<div className="wt-queue-item-info">
{item.url.match(/youtu/) && (
<img
className="wt-queue-thumb"
src={`https://img.youtube.com/vi/${item.url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/)?.[1]}/default.jpg`}
alt=""
/>
)}
<div className="wt-queue-item-text">
<div className="wt-queue-item-title">{item.title || item.url}</div>
<div className="wt-queue-item-by">{item.addedBy}</div>
</div>
</div>
{isHost && (
<button className="wt-queue-item-remove" onClick={() => removeFromQueue(i)} title="Entfernen">
{'\u00D7'}
</button>
)}
</div>
))
)}
</div>
{currentRoom.history && currentRoom.history.length > 0 && (
<div className="wt-history-section">
<button className="wt-history-toggle" onClick={() => setShowHistory(!showHistory)}>
{showHistory ? '\u25BC' : '\u25B6'} Verlauf ({currentRoom.history.length})
</button>
{showHistory && (
<div className="wt-history-list">
{[...currentRoom.history].reverse().map((item, i) => (
<div key={i} className="wt-history-item">
<div className="wt-history-item-title">{item.title || item.url}</div>
<div className="wt-history-item-by">{item.addedBy}</div>
</div>
))}
</div>
)}
</div>
)}
<div className="wt-queue-add">
<input
className="wt-input wt-queue-input"
placeholder="Video-URL eingeben"
value={queueUrl}
onChange={e => setQueueUrl(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addToQueue(); }}
/>
<button className="wt-btn wt-queue-add-btn" onClick={addToQueue} disabled={addingToQueue}>
{addingToQueue ? 'Laden...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
</div>
);
}
// ── Render: Lobby ──
return (
<div className="wt-container">
{error && (
<div className="wt-error">
{error}
<button className="wt-error-dismiss" onClick={() => setError(null)}>{'\u00D7'}</button>
</div>
)}
<div className="wt-topbar">
<input
className="wt-input wt-input-name"
placeholder="Dein Name"
value={userName}
onChange={e => setUserName(e.target.value)}
/>
<input
className="wt-input wt-input-room"
placeholder="Raumname"
value={roomName}
onChange={e => setRoomName(e.target.value)}
/>
<input
className="wt-input wt-input-password"
type="password"
placeholder="Passwort (optional)"
value={roomPassword}
onChange={e => setRoomPassword(e.target.value)}
/>
<button className="wt-btn" onClick={createRoom}>Raum erstellen</button>
</div>
{rooms.length === 0 ? (
<div className="wt-empty">
<div className="wt-empty-icon">{'\uD83C\uDFAC'}</div>
<h3>Keine aktiven Raeume</h3>
<p>Erstelle einen Raum, um gemeinsam Videos zu schauen.</p>
</div>
) : (
<div className="wt-grid">
{rooms.map(room => (
<div key={room.id} className="wt-tile" onClick={() => handleTileClick(room)}>
<div className="wt-tile-preview">
<span className="wt-tile-icon">{'\uD83C\uDFAC'}</span>
<span className="wt-tile-members">{'\uD83D\uDC65'} {room.memberCount}</span>
{room.hasPassword && <span className="wt-tile-lock">{'\uD83D\uDD12'}</span>}
{room.playing && <span className="wt-tile-playing">{'\u25B6'}</span>}
</div>
<div className="wt-tile-info">
<div className="wt-tile-meta">
<div className="wt-tile-name">{room.name}</div>
<div className="wt-tile-host">{room.hostName}</div>
</div>
</div>
</div>
))}
</div>
)}
{joinModal && (
<div className="wt-modal-overlay" onClick={() => setJoinModal(null)}>
<div className="wt-modal" onClick={e => e.stopPropagation()}>
<h3>{joinModal.roomName}</h3>
<p>Raum-Passwort</p>
{joinModal.error && <div className="wt-modal-error">{joinModal.error}</div>}
<input
className="wt-input"
type="password"
placeholder="Passwort"
value={joinModal.password}
onChange={e => setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)}
onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }}
autoFocus
/>
<div className="wt-modal-actions">
<button className="wt-modal-cancel" onClick={() => setJoinModal(null)}>Abbrechen</button>
<button className="wt-btn" onClick={submitJoinModal}>Beitreten</button>
</div>
</div>
</div>
)}
</div>
);
}