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 }>; } 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([]); const [userName, setUserName] = useState(() => localStorage.getItem('wt_name') || ''); const [roomName, setRoomName] = useState(''); const [roomPassword, setRoomPassword] = useState(''); const [currentRoom, setCurrentRoom] = useState(null); const [joinModal, setJoinModal] = useState(null); const [error, setError] = useState(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); // ── Refs ── const wsRef = useRef(null); const clientIdRef = useRef(''); const reconnectTimerRef = useRef | null>(null); const reconnectDelayRef = useRef(1000); const currentRoomRef = useRef(null); const roomContainerRef = useRef(null); // Player refs const ytPlayerRef = useRef(null); const videoRef = useRef(null); const playerContainerRef = useRef(null); const currentVideoTypeRef = useRef<'youtube' | 'direct' | null>(null); const ytReadyRef = useRef(false); const seekingRef = useRef(false); const timeUpdateRef = useRef | 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) => { 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(); 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' }); } } }, }, }); } 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': setCurrentRoom({ id: msg.roomId, name: msg.name, hostId: msg.hostId, members: msg.members || [], currentVideo: null, playing: false, currentTime: 0, queue: [], }); break; case 'room_joined': setCurrentRoom({ id: msg.roomId, name: msg.name, hostId: msg.hostId, members: msg.members || [], currentVideo: msg.currentVideo || null, playing: msg.playing || false, currentTime: msg.currentTime || 0, queue: msg.queue || [], }); // Load video if one is playing if (msg.currentVideo?.url) { setTimeout(() => loadVideo(msg.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, } : 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) 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 = () => { 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 waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsSend({ type: 'create_room', name: userName.trim(), roomName: roomName.trim(), password: roomPassword.trim() || undefined, }); } 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 waitForWs = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsSend({ type: 'join_room', name: userName.trim(), roomId, password: password?.trim() || undefined, }); } 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(() => { if (!queueUrl.trim()) return; wsSend({ type: 'add_to_queue', url: queueUrl.trim() }); setQueueUrl(''); }, [queueUrl, wsSend]); // ── Remove from queue ── const removeFromQueue = useCallback((index: number) => { wsSend({ type: 'remove_from_queue', index }); }, [wsSend]); // ── Playback controls (host only) ── const togglePlay = useCallback(() => { wsSend({ type: 'toggle_play' }); }, [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 (
{currentRoom.name} {currentRoom.members.length} Mitglieder {hostMember && Host: {hostMember.name}}
{isHost ? ( <> seek(parseFloat(e.target.value))} disabled={!currentRoom.currentVideo} /> ) : ( <> {currentRoom.playing ? '\u25B6' : '\u23F8'}
0 ? `${(currentTime / duration) * 100}%` : '0%' }} />
)} {formatTime(currentTime)} / {formatTime(duration)}
{volume === 0 ? '\uD83D\uDD07' : volume < 0.5 ? '\uD83D\uDD09' : '\uD83D\uDD0A'} setVolume(parseFloat(e.target.value))} />
Warteschlange ({currentRoom.queue.length})
{currentRoom.queue.length === 0 ? (
Keine Videos in der Warteschlange
) : ( currentRoom.queue.map((item, i) => (
{item.title || item.url}
{item.addedBy}
{isHost && ( )}
)) )}
setQueueUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addToQueue(); }} />
); } // ── Render: Lobby ── return (
{error && (
{error}
)}
setUserName(e.target.value)} /> setRoomName(e.target.value)} /> setRoomPassword(e.target.value)} />
{rooms.length === 0 ? (
{'\uD83C\uDFAC'}

Keine aktiven Raeume

Erstelle einen Raum, um gemeinsam Videos zu schauen.

) : (
{rooms.map(room => (
handleTileClick(room)}>
{'\uD83C\uDFAC'} {'\uD83D\uDC65'} {room.memberCount} {room.hasPassword && {'\uD83D\uDD12'}} {room.playing && {'\u25B6'}}
{room.name}
{room.hostName}
))}
)} {joinModal && (
setJoinModal(null)}>
e.stopPropagation()}>

{joinModal.roomName}

Raum-Passwort

{joinModal.error &&
{joinModal.error}
} setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)} onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }} autoFocus />
)}
); }