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:
Daniel 2026-03-07 02:40:59 +01:00
parent 4943bbf4a1
commit 73f247ada3
16 changed files with 7386 additions and 4833 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4830
web/dist/assets/index-ZMOZU_VE.js vendored Normal file

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-UqZEdiQO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKX7sma7.css">
<script type="module" crossorigin src="/assets/index-ZMOZU_VE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C2eno-Si.css">
</head>
<body>
<div id="root"></div>

View file

@ -3,6 +3,7 @@ import RadioTab from './plugins/radio/RadioTab';
import SoundboardTab from './plugins/soundboard/SoundboardTab';
import LolstatsTab from './plugins/lolstats/LolstatsTab';
import StreamingTab from './plugins/streaming/StreamingTab';
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
interface PluginInfo {
name: string;
@ -16,6 +17,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
soundboard: SoundboardTab,
lolstats: LolstatsTab,
streaming: StreamingTab,
'watch-together': WatchTogetherTab,
};
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
@ -101,6 +103,7 @@ export default function App() {
games: '\u{1F3B2}',
gamevote: '\u{1F3AE}',
streaming: '\u{1F4FA}',
'watch-together': '\u{1F3AC}',
};
return (
@ -127,6 +130,15 @@ export default function App() {
</nav>
<div className="hub-header-right">
{!(window as any).electronAPI && (
<a
className="hub-download-btn"
href="/downloads/GamingHub-Setup.exe"
title="Desktop App herunterladen"
>
{'\u2B07\uFE0F'}
</a>
)}
<span className="hub-version">v{version}</span>
</div>
</header>

View 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>
);
}

View file

@ -0,0 +1,730 @@
/* ── Watch Together Plugin ── */
.wt-container {
height: 100%;
overflow-y: auto;
padding: 16px;
}
/* ── Top Bar ── */
.wt-topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.wt-input {
padding: 10px 14px;
border: 1px solid var(--bg-tertiary);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-normal);
font-size: 14px;
outline: none;
transition: border-color var(--transition);
min-width: 0;
}
.wt-input:focus { border-color: var(--accent); }
.wt-input::placeholder { color: var(--text-faint); }
.wt-input-name { width: 150px; }
.wt-input-room { flex: 1; min-width: 180px; }
.wt-input-password { width: 170px; }
.wt-btn {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background var(--transition);
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.wt-btn:hover { background: var(--accent-hover); }
.wt-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Grid ── */
.wt-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
/* ── Tile ── */
.wt-tile {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition);
position: relative;
}
.wt-tile:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
/* Preview area (16:9) */
.wt-tile-preview {
position: relative;
width: 100%;
padding-top: 56.25%;
background: var(--bg-deep);
overflow: hidden;
}
.wt-tile-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 48px;
opacity: 0.3;
}
/* Member count on tile */
.wt-tile-members {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
}
/* Lock icon on tile */
.wt-tile-lock {
position: absolute;
bottom: 8px;
right: 8px;
font-size: 16px;
opacity: 0.6;
}
/* Playing indicator on tile */
.wt-tile-playing {
position: absolute;
top: 8px;
left: 8px;
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
}
/* Info bar below preview */
.wt-tile-info {
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wt-tile-meta {
min-width: 0;
flex: 1;
}
.wt-tile-name {
font-size: 14px;
font-weight: 600;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wt-tile-host {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Empty state ── */
.wt-empty {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.wt-empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.4;
}
.wt-empty h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-normal);
margin-bottom: 6px;
}
.wt-empty p {
font-size: 14px;
}
/* ── Error ── */
.wt-error {
background: rgba(237, 66, 69, 0.12);
color: var(--danger);
padding: 10px 14px;
border-radius: var(--radius);
font-size: 14px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.wt-error-dismiss {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
margin-left: auto;
font-size: 16px;
padding: 0 4px;
}
/* ── Password / Join Modal ── */
.wt-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
}
.wt-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 24px;
width: 340px;
max-width: 90vw;
}
.wt-modal h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.wt-modal p {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 16px;
}
.wt-modal .wt-input {
width: 100%;
margin-bottom: 12px;
}
.wt-modal-error {
color: var(--danger);
font-size: 13px;
margin-bottom: 8px;
}
.wt-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.wt-modal-cancel {
padding: 8px 16px;
border: 1px solid var(--bg-tertiary);
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
}
.wt-modal-cancel:hover {
color: var(--text-normal);
border-color: var(--text-faint);
}
/*
ROOM VIEW (Fullscreen Overlay)
*/
.wt-room-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: #000;
display: flex;
flex-direction: column;
}
/* ── Room Header ── */
.wt-room-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
z-index: 1;
flex-shrink: 0;
}
.wt-room-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.wt-room-name {
font-weight: 600;
font-size: 16px;
}
.wt-room-members {
font-size: 13px;
color: var(--text-muted);
}
.wt-host-badge {
font-size: 11px;
background: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
color: var(--accent);
font-weight: 600;
}
.wt-room-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.wt-fullscreen-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
width: 36px;
height: 36px;
border-radius: var(--radius);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition);
}
.wt-fullscreen-btn:hover {
background: rgba(255, 255, 255, 0.25);
}
.wt-leave-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
transition: background var(--transition);
}
.wt-leave-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ── Room Body ── */
.wt-room-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Player Section ── */
.wt-player-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.wt-player-wrap {
position: relative;
width: 100%;
flex: 1;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.wt-yt-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.wt-yt-container iframe {
width: 100%;
height: 100%;
}
.wt-video-element {
width: 100%;
height: 100%;
object-fit: contain;
}
.wt-player-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-muted);
font-size: 16px;
}
.wt-placeholder-icon {
font-size: 48px;
opacity: 0.3;
}
/* ── Controls ── */
.wt-controls {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.8);
flex-shrink: 0;
}
.wt-ctrl-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
width: 36px;
height: 36px;
border-radius: var(--radius);
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition);
flex-shrink: 0;
}
.wt-ctrl-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
}
.wt-ctrl-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.wt-ctrl-status {
color: var(--text-muted);
font-size: 16px;
width: 36px;
text-align: center;
flex-shrink: 0;
}
/* ── Seek Slider ── */
.wt-seek {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
outline: none;
cursor: pointer;
min-width: 60px;
}
.wt-seek::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.wt-seek::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.wt-seek::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
}
.wt-seek::-moz-range-track {
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
}
/* Read-only seek for non-host */
.wt-seek-readonly {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
position: relative;
overflow: hidden;
min-width: 60px;
}
.wt-seek-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s linear;
}
/* ── Time Display ── */
.wt-time {
font-variant-numeric: tabular-nums;
color: #fff;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Volume ── */
.wt-volume {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.wt-volume-icon {
font-size: 16px;
width: 20px;
text-align: center;
cursor: default;
}
.wt-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100px;
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
outline: none;
cursor: pointer;
}
.wt-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.wt-volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: none;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
/*
QUEUE PANEL
*/
.wt-queue-panel {
width: 280px;
background: var(--bg-secondary);
border-left: 1px solid var(--bg-tertiary);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.wt-queue-header {
padding: 14px 16px;
font-weight: 600;
font-size: 14px;
color: var(--text-normal);
border-bottom: 1px solid var(--bg-tertiary);
flex-shrink: 0;
}
.wt-queue-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--bg-tertiary) transparent;
}
.wt-queue-list::-webkit-scrollbar {
width: 4px;
}
.wt-queue-list::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 2px;
}
.wt-queue-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-faint);
font-size: 13px;
}
.wt-queue-item {
padding: 10px 12px;
border-bottom: 1px solid var(--bg-tertiary);
display: flex;
align-items: center;
gap: 8px;
transition: background var(--transition);
}
.wt-queue-item:hover {
background: var(--bg-tertiary);
}
.wt-queue-item.playing {
border-left: 3px solid var(--accent);
background: rgba(230, 126, 34, 0.08);
}
.wt-queue-item-info {
flex: 1;
min-width: 0;
}
.wt-queue-item-title {
font-size: 13px;
font-weight: 500;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wt-queue-item-by {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.wt-queue-item-remove {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 18px;
padding: 2px 6px;
border-radius: 4px;
transition: all var(--transition);
flex-shrink: 0;
}
.wt-queue-item-remove:hover {
color: var(--danger);
background: rgba(237, 66, 69, 0.12);
}
/* ── Queue Add ── */
.wt-queue-add {
padding: 12px;
display: flex;
gap: 8px;
border-top: 1px solid var(--bg-tertiary);
flex-shrink: 0;
}
.wt-queue-input {
flex: 1;
font-size: 13px;
padding: 8px 10px;
}
.wt-queue-add-btn {
padding: 8px 12px;
font-size: 13px;
flex-shrink: 0;
}
/*
RESPONSIVE
*/
@media (max-width: 768px) {
.wt-room-body {
flex-direction: column;
}
.wt-queue-panel {
width: 100%;
max-height: 40vh;
border-left: none;
border-top: 1px solid var(--bg-tertiary);
}
.wt-player-wrap {
min-height: 200px;
}
.wt-controls {
flex-wrap: wrap;
gap: 8px;
padding: 10px 12px;
}
.wt-volume {
margin-left: 0;
}
.wt-volume-slider {
width: 80px;
}
.wt-room-header {
padding: 10px 12px;
}
.wt-room-name {
font-size: 14px;
}
.wt-room-members,
.wt-host-badge {
font-size: 11px;
}
}
@media (max-width: 480px) {
.wt-topbar {
gap: 8px;
}
.wt-input-name {
width: 100%;
}
.wt-input-room {
min-width: 0;
}
.wt-input-password {
width: 100%;
}
.wt-volume-slider {
width: 60px;
}
.wt-time {
font-size: 12px;
}
.wt-host-badge {
display: none;
}
}

View file

@ -188,6 +188,17 @@ html, body {
flex-shrink: 0;
}
.hub-download-btn {
font-size: 16px;
text-decoration: none;
opacity: 0.6;
transition: opacity var(--transition);
cursor: pointer;
}
.hub-download-btn:hover {
opacity: 1;
}
.hub-version {
font-size: 12px;
color: var(--text-faint);