Watch Together Plugin + Electron Desktop App mit Ad-Blocker
Neuer Tab: Watch Together - gemeinsam Videos schauen (w2g.tv-Style) - Raum-System mit optionalem Passwort und Host-Kontrolle - Video-Queue mit Hinzufuegen/Entfernen/Umordnen - YouTube (IFrame API) + direkte Video-URLs (.mp4, .webm) - Synchronisierte Wiedergabe via WebSocket (/ws/watch-together) - Server-autoritative Playback-State mit Drift-Korrektur (2.5s Sync-Pulse) - Host-Transfer bei Disconnect, Room-Cleanup nach 30s Electron Desktop App (electron/): - Wrapper fuer Gaming Hub mit integriertem Ad-Blocker - uBlock-Style Request-Filtering via session.webRequest - 100+ Ad-Domains + YouTube-spezifische Filter - Download-Button im Web-Header (nur sichtbar wenn nicht in Electron) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4943bbf4a1
commit
73f247ada3
16 changed files with 7386 additions and 4833 deletions
758
web/src/plugins/watch-together/WatchTogetherTab.tsx
Normal file
758
web/src/plugins/watch-together/WatchTogetherTab.tsx
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue