2026-03-07 02:40:59 +01:00
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
|
import './watch-together.css';
|
|
|
|
|
|
|
|
|
|
// ── Types ──
|
|
|
|
|
|
|
|
|
|
interface RoomInfo {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
hostName: string;
|
|
|
|
|
memberCount: number;
|
2026-03-07 22:38:51 +01:00
|
|
|
memberNames: string[];
|
2026-03-07 02:40:59 +01:00
|
|
|
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;
|
2026-03-07 22:23:43 +01:00
|
|
|
queue: Array<{ url: string; title: string; addedBy: string; watched: boolean }>;
|
2026-03-07 02:40:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-07 11:19:59 +01:00
|
|
|
const [playerError, setPlayerError] = useState<string | null>(null);
|
|
|
|
|
const [addingToQueue, setAddingToQueue] = useState(false);
|
2026-03-07 22:38:51 +01:00
|
|
|
const [chatMessages, setChatMessages] = useState<Array<{ sender: string; text: string; timestamp: number }>>([]);
|
|
|
|
|
const [chatInput, setChatInput] = useState('');
|
|
|
|
|
const [votes, setVotes] = useState<{ skip: number; pause: number; total: number; needed: number } | null>(null);
|
|
|
|
|
const [syncStatus, setSyncStatus] = useState<'synced' | 'drifting' | 'desynced'>('synced');
|
|
|
|
|
const [showChat, setShowChat] = useState(true);
|
2026-03-07 02:40:59 +01:00
|
|
|
|
|
|
|
|
// ── 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);
|
2026-03-07 22:38:51 +01:00
|
|
|
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
2026-03-07 02:40:59 +01:00
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
|
2026-03-07 22:38:51 +01:00
|
|
|
// ── Auto-scroll chat ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
|
|
|
}, [chatMessages]);
|
|
|
|
|
|
|
|
|
|
// ── Join via link ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
const roomId = params.get('wt');
|
|
|
|
|
if (roomId && userName.trim()) {
|
|
|
|
|
// Delay to let WS connect first
|
|
|
|
|
const timer = setTimeout(() => joinRoom(roomId), 1500);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 02:40:59 +01:00
|
|
|
// ── 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();
|
2026-03-07 11:19:59 +01:00
|
|
|
setPlayerError(null);
|
2026-03-07 02:40:59 +01:00
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-07 11:19:59 +01:00
|
|
|
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);
|
|
|
|
|
},
|
2026-03-07 02:40:59 +01:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} 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;
|
|
|
|
|
|
2026-03-07 02:48:43 +01:00
|
|
|
case 'room_created': {
|
|
|
|
|
const r = msg.room;
|
2026-03-07 02:40:59 +01:00
|
|
|
setCurrentRoom({
|
2026-03-07 02:48:43 +01:00
|
|
|
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 || [],
|
2026-03-07 02:40:59 +01:00
|
|
|
});
|
|
|
|
|
break;
|
2026-03-07 02:48:43 +01:00
|
|
|
}
|
2026-03-07 02:40:59 +01:00
|
|
|
|
2026-03-07 02:48:43 +01:00
|
|
|
case 'room_joined': {
|
|
|
|
|
const r = msg.room;
|
2026-03-07 02:40:59 +01:00
|
|
|
setCurrentRoom({
|
2026-03-07 02:48:43 +01:00
|
|
|
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 || [],
|
2026-03-07 02:40:59 +01:00
|
|
|
});
|
2026-03-07 02:48:43 +01:00
|
|
|
if (r.currentVideo?.url) {
|
|
|
|
|
setTimeout(() => loadVideo(r.currentVideo.url), 100);
|
2026-03-07 02:40:59 +01:00
|
|
|
}
|
|
|
|
|
break;
|
2026-03-07 02:48:43 +01:00
|
|
|
}
|
2026-03-07 02:40:59 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 22:38:51 +01:00
|
|
|
// Sync status
|
|
|
|
|
if (msg.currentTime !== undefined) {
|
|
|
|
|
const localTime = getCurrentTime();
|
|
|
|
|
if (localTime !== null) {
|
|
|
|
|
const drift = Math.abs(localTime - msg.currentTime);
|
|
|
|
|
if (drift < 1.5) setSyncStatus('synced');
|
|
|
|
|
else if (drift < 5) setSyncStatus('drifting');
|
|
|
|
|
else setSyncStatus('desynced');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 02:40:59 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-03-07 22:38:51 +01:00
|
|
|
case 'vote_updated':
|
|
|
|
|
setVotes(msg.votes);
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'chat':
|
|
|
|
|
setChatMessages(prev => {
|
|
|
|
|
const next = [...prev, { sender: msg.sender, text: msg.text, timestamp: msg.timestamp }];
|
|
|
|
|
return next.length > 100 ? next.slice(-100) : next;
|
|
|
|
|
});
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'chat_history':
|
|
|
|
|
setChatMessages(msg.messages || []);
|
|
|
|
|
break;
|
|
|
|
|
|
2026-03-07 02:40:59 +01:00
|
|
|
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(() => {
|
2026-03-07 11:02:36 +01:00
|
|
|
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
|
2026-03-07 02:40:59 +01:00
|
|
|
|
|
|
|
|
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 = () => {
|
2026-03-07 11:02:36 +01:00
|
|
|
if (wsRef.current === ws) {
|
|
|
|
|
wsRef.current = null;
|
|
|
|
|
}
|
2026-03-07 02:40:59 +01:00
|
|
|
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();
|
|
|
|
|
|
2026-03-07 11:02:36 +01:00
|
|
|
const startTime = Date.now();
|
2026-03-07 02:40:59 +01:00
|
|
|
const waitForWs = () => {
|
|
|
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
|
|
|
wsSend({
|
|
|
|
|
type: 'create_room',
|
2026-03-07 02:48:43 +01:00
|
|
|
name: roomName.trim(),
|
|
|
|
|
userName: userName.trim(),
|
2026-03-07 02:40:59 +01:00
|
|
|
password: roomPassword.trim() || undefined,
|
|
|
|
|
});
|
2026-03-07 11:02:36 +01:00
|
|
|
} else if (Date.now() - startTime > 10000) {
|
|
|
|
|
setError('Verbindung zum Server fehlgeschlagen.');
|
2026-03-07 02:40:59 +01:00
|
|
|
} 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();
|
|
|
|
|
|
2026-03-07 11:02:36 +01:00
|
|
|
const startTime = Date.now();
|
2026-03-07 02:40:59 +01:00
|
|
|
const waitForWs = () => {
|
|
|
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
|
|
|
wsSend({
|
|
|
|
|
type: 'join_room',
|
2026-03-07 02:48:43 +01:00
|
|
|
userName: userName.trim(),
|
2026-03-07 02:40:59 +01:00
|
|
|
roomId,
|
|
|
|
|
password: password?.trim() || undefined,
|
|
|
|
|
});
|
2026-03-07 11:02:36 +01:00
|
|
|
} else if (Date.now() - startTime > 10000) {
|
|
|
|
|
setError('Verbindung zum Server fehlgeschlagen.');
|
2026-03-07 02:40:59 +01:00
|
|
|
} 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);
|
2026-03-07 22:38:51 +01:00
|
|
|
setChatMessages([]);
|
|
|
|
|
setVotes(null);
|
|
|
|
|
setSyncStatus('synced');
|
2026-03-07 02:40:59 +01:00
|
|
|
}, [wsSend, destroyPlayer]);
|
|
|
|
|
|
|
|
|
|
// ── Add to queue ──
|
2026-03-07 11:19:59 +01:00
|
|
|
const addToQueue = useCallback(async () => {
|
|
|
|
|
const url = queueUrl.trim();
|
|
|
|
|
if (!url) return;
|
2026-03-07 02:40:59 +01:00
|
|
|
setQueueUrl('');
|
2026-03-07 11:19:59 +01:00
|
|
|
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);
|
2026-03-07 02:40:59 +01:00
|
|
|
}, [queueUrl, wsSend]);
|
|
|
|
|
|
2026-03-07 22:38:51 +01:00
|
|
|
// ── Send chat ──
|
|
|
|
|
const sendChat = useCallback(() => {
|
|
|
|
|
const text = chatInput.trim();
|
|
|
|
|
if (!text) return;
|
|
|
|
|
setChatInput('');
|
|
|
|
|
wsSend({ type: 'chat_message', text });
|
|
|
|
|
}, [chatInput, wsSend]);
|
|
|
|
|
|
|
|
|
|
// ── Vote callbacks ──
|
|
|
|
|
const voteSkip = useCallback(() => {
|
|
|
|
|
wsSend({ type: 'vote_skip' });
|
|
|
|
|
}, [wsSend]);
|
|
|
|
|
|
|
|
|
|
const votePause = useCallback(() => {
|
|
|
|
|
wsSend({ type: 'vote_pause' });
|
|
|
|
|
}, [wsSend]);
|
|
|
|
|
|
|
|
|
|
// ── Clear watched ──
|
|
|
|
|
const clearWatched = useCallback(() => {
|
|
|
|
|
wsSend({ type: 'clear_watched' });
|
|
|
|
|
}, [wsSend]);
|
|
|
|
|
|
|
|
|
|
// ── Copy room link ──
|
|
|
|
|
const copyRoomLink = useCallback(() => {
|
|
|
|
|
if (!currentRoom) return;
|
|
|
|
|
const url = `${window.location.origin}${window.location.pathname}?wt=${currentRoom.id}`;
|
|
|
|
|
navigator.clipboard.writeText(url).catch(() => {});
|
|
|
|
|
}, [currentRoom]);
|
|
|
|
|
|
2026-03-07 02:40:59 +01:00
|
|
|
// ── Remove from queue ──
|
|
|
|
|
const removeFromQueue = useCallback((index: number) => {
|
|
|
|
|
wsSend({ type: 'remove_from_queue', index });
|
|
|
|
|
}, [wsSend]);
|
|
|
|
|
|
|
|
|
|
// ── Playback controls (host only) ──
|
|
|
|
|
const togglePlay = useCallback(() => {
|
2026-03-07 02:48:43 +01:00
|
|
|
const room = currentRoomRef.current;
|
|
|
|
|
if (!room) return;
|
|
|
|
|
wsSend({ type: room.playing ? 'pause' : 'resume' });
|
2026-03-07 02:40:59 +01:00
|
|
|
}, [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">
|
2026-03-07 22:38:51 +01:00
|
|
|
<div className={`wt-sync-dot wt-sync-${syncStatus}`} title={syncStatus === 'synced' ? 'Synchron' : syncStatus === 'drifting' ? 'Leichte Verzögerung' : 'Nicht synchron'} />
|
|
|
|
|
<button className="wt-header-btn" onClick={copyRoomLink} title="Link kopieren">Link</button>
|
|
|
|
|
<button className="wt-header-btn" onClick={() => setShowChat(c => !c)} title={showChat ? 'Chat ausblenden' : 'Chat einblenden'}>Chat</button>
|
2026-03-07 02:40:59 +01:00
|
|
|
<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
|
|
|
|
|
/>
|
2026-03-07 11:19:59 +01:00
|
|
|
{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 öffnen ↗
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
<p className="wt-skip-info">Wird in 3 Sekunden übersprungen...</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-07 02:40:59 +01:00
|
|
|
{!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>
|
2026-03-07 15:31:56 +01:00
|
|
|
<button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video">
|
|
|
|
|
{'\u23ED'} Weiter
|
2026-03-07 02:40:59 +01:00
|
|
|
</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}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-03-07 22:38:51 +01:00
|
|
|
<button className="wt-ctrl-btn wt-vote-btn" onClick={votePause} title="Pause abstimmen">
|
|
|
|
|
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
|
|
|
|
</button>
|
|
|
|
|
<button className="wt-ctrl-btn wt-vote-btn" onClick={voteSkip} title="Skip abstimmen">
|
|
|
|
|
{'\u23ED'}
|
|
|
|
|
</button>
|
|
|
|
|
{votes && votes.skip > 0 && (
|
|
|
|
|
<span className="wt-vote-count">{votes.skip}/{votes.needed} Skip</span>
|
|
|
|
|
)}
|
|
|
|
|
{votes && votes.pause > 0 && (
|
|
|
|
|
<span className="wt-vote-count">{votes.pause}/{votes.needed} Pause</span>
|
|
|
|
|
)}
|
2026-03-07 02:40:59 +01:00
|
|
|
<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">
|
2026-03-07 22:38:51 +01:00
|
|
|
<div className="wt-queue-header">
|
|
|
|
|
<span>Warteschlange ({currentRoom.queue.length})</span>
|
|
|
|
|
{isHost && currentRoom.queue.some(q => q.watched) && (
|
|
|
|
|
<button className="wt-queue-clear-btn" onClick={clearWatched} title="Gesehene entfernen">Gesehene entfernen</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-07 02:40:59 +01:00
|
|
|
<div className="wt-queue-list">
|
|
|
|
|
{currentRoom.queue.length === 0 ? (
|
|
|
|
|
<div className="wt-queue-empty">Keine Videos in der Warteschlange</div>
|
|
|
|
|
) : (
|
2026-03-07 22:23:43 +01:00
|
|
|
currentRoom.queue.map((item, i) => {
|
|
|
|
|
const isCurrent = currentRoom.currentVideo?.url === item.url;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className={`wt-queue-item${isCurrent ? ' playing' : ''}${item.watched && !isCurrent ? ' watched' : ''}${isHost ? ' clickable' : ''}`}
|
|
|
|
|
onClick={() => isHost && wsSend({ type: 'play_video', index: i })}
|
|
|
|
|
title={isHost ? 'Klicken zum Abspielen' : undefined}
|
|
|
|
|
>
|
|
|
|
|
<div className="wt-queue-item-info">
|
|
|
|
|
{item.watched && !isCurrent && <span className="wt-queue-item-check">{'\u2713'}</span>}
|
|
|
|
|
{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>
|
2026-03-07 15:31:56 +01:00
|
|
|
</div>
|
2026-03-07 22:23:43 +01:00
|
|
|
{isHost && (
|
|
|
|
|
<button className="wt-queue-item-remove" onClick={(e) => { e.stopPropagation(); removeFromQueue(i); }} title="Entfernen">
|
|
|
|
|
{'\u00D7'}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-03-07 02:40:59 +01:00
|
|
|
</div>
|
2026-03-07 22:23:43 +01:00
|
|
|
);
|
|
|
|
|
})
|
2026-03-07 02:40:59 +01:00
|
|
|
)}
|
|
|
|
|
</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(); }}
|
|
|
|
|
/>
|
2026-03-07 11:19:59 +01:00
|
|
|
<button className="wt-btn wt-queue-add-btn" onClick={addToQueue} disabled={addingToQueue}>
|
|
|
|
|
{addingToQueue ? 'Laden...' : 'Hinzufuegen'}
|
|
|
|
|
</button>
|
2026-03-07 02:40:59 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-07 22:38:51 +01:00
|
|
|
|
|
|
|
|
{showChat && (
|
|
|
|
|
<div className="wt-chat-panel">
|
|
|
|
|
<div className="wt-chat-header">Chat</div>
|
|
|
|
|
<div className="wt-chat-messages">
|
|
|
|
|
{chatMessages.length === 0 ? (
|
|
|
|
|
<div className="wt-chat-empty">Noch keine Nachrichten</div>
|
|
|
|
|
) : (
|
|
|
|
|
chatMessages.map((msg, i) => (
|
|
|
|
|
<div key={i} className="wt-chat-msg">
|
|
|
|
|
<span className="wt-chat-sender">{msg.sender}</span>
|
|
|
|
|
<span className="wt-chat-text">{msg.text}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
<div ref={chatEndRef} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="wt-chat-input-row">
|
|
|
|
|
<input
|
|
|
|
|
className="wt-input wt-chat-input"
|
|
|
|
|
placeholder="Nachricht..."
|
|
|
|
|
value={chatInput}
|
|
|
|
|
onChange={e => setChatInput(e.target.value)}
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter') sendChat(); }}
|
|
|
|
|
maxLength={500}
|
|
|
|
|
/>
|
|
|
|
|
<button className="wt-btn wt-chat-send-btn" onClick={sendChat}>Senden</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-07 02:40:59 +01:00
|
|
|
</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>
|
2026-03-07 22:38:51 +01:00
|
|
|
{room.memberNames && room.memberNames.length > 0 && (
|
|
|
|
|
<div className="wt-tile-members-list">
|
|
|
|
|
{room.memberNames.slice(0, 5).join(', ')}
|
|
|
|
|
{room.memberNames.length > 5 && ` +${room.memberNames.length - 5}`}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-07 02:40:59 +01:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|