759 lines
26 KiB
TypeScript
759 lines
26 KiB
TypeScript
|
|
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<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);
|
||
|
|
|
||
|
|
// ── 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();
|
||
|
|
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 (
|
||
|
|
<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
|
||
|
|
/>
|
||
|
|
{!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" onClick={skip} disabled={!currentRoom.currentVideo} title="Weiter">
|
||
|
|
{'\u23ED'}
|
||
|
|
</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' : ''}`}
|
||
|
|
>
|
||
|
|
<div className="wt-queue-item-info">
|
||
|
|
<div className="wt-queue-item-title">{item.title || item.url}</div>
|
||
|
|
<div className="wt-queue-item-by">{item.addedBy}</div>
|
||
|
|
</div>
|
||
|
|
{isHost && (
|
||
|
|
<button className="wt-queue-item-remove" onClick={() => removeFromQueue(i)} title="Entfernen">
|
||
|
|
{'\u00D7'}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</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}>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>
|
||
|
|
);
|
||
|
|
}
|