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

87
electron/ad-blocker.js Normal file
View file

@ -0,0 +1,87 @@
const fs = require('fs');
const path = require('path');
function setupAdBlocker(session) {
// Load filter domains
const filtersPath = path.join(__dirname, 'filters.txt');
let rawFilters;
try {
rawFilters = fs.readFileSync(filtersPath, 'utf-8');
} catch (err) {
console.error('[AdBlocker] Could not load filters.txt:', err.message);
return;
}
// Parse: each line is either a domain, a URL path pattern, or a regex
const blockedDomains = new Set();
const blockedPatterns = [];
for (const line of rawFilters.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.startsWith('/') && trimmed.endsWith('/')) {
// Regex pattern
try {
blockedPatterns.push(new RegExp(trimmed.slice(1, -1)));
} catch {
// invalid regex, skip
}
} else if (trimmed.includes('/')) {
// URL path pattern (string match)
blockedPatterns.push(trimmed);
} else {
// Domain
blockedDomains.add(trimmed);
}
}
let blockedCount = 0;
session.webRequest.onBeforeRequest((details, callback) => {
try {
const url = new URL(details.url);
const hostname = url.hostname;
// Check domain blocklist (including subdomains)
for (const domain of blockedDomains) {
if (hostname === domain || hostname.endsWith('.' + domain)) {
blockedCount++;
callback({ cancel: true });
return;
}
}
// Check URL patterns
const fullUrl = details.url;
for (const pattern of blockedPatterns) {
if (pattern instanceof RegExp) {
if (pattern.test(fullUrl)) {
blockedCount++;
callback({ cancel: true });
return;
}
} else if (fullUrl.includes(pattern)) {
blockedCount++;
callback({ cancel: true });
return;
}
}
} catch {
// URL parsing error, allow request
}
callback({});
});
// Log stats periodically
setInterval(() => {
if (blockedCount > 0) {
console.log(`[AdBlocker] ${blockedCount} requests blocked`);
}
}, 30000);
console.log(`[AdBlocker] Loaded ${blockedDomains.size} domains + ${blockedPatterns.length} patterns`);
}
module.exports = { setupAdBlocker };

253
electron/filters.txt Normal file
View file

@ -0,0 +1,253 @@
# ============================================================
# Gaming Hub Ad-Blocker Filter List
# Focused on YouTube ads, general ad networks, and trackers
# ============================================================
# ------------------------------------------------------------
# Google Ads & Ad Networks
# ------------------------------------------------------------
doubleclick.net
googlesyndication.com
googleadservices.com
google-analytics.com
googletagmanager.com
googletagservices.com
adservice.google.com
pagead2.googlesyndication.com
ads.google.com
adclick.g.doubleclick.net
googleads.g.doubleclick.net
www.googleadservices.com
partner.googleadservices.com
tpc.googlesyndication.com
# ------------------------------------------------------------
# Major Ad Exchanges & Programmatic
# ------------------------------------------------------------
adnxs.com
adsrvr.org
adform.net
amazon-adsystem.com
adsymptotic.com
adtilt.com
advertising.com
bidswitch.net
casalemedia.com
criteo.com
criteo.net
demdex.net
dotomi.com
exelator.com
eyeblaster.com
flashtalking.com
moat.com
moatads.com
mookie1.com
pubmatic.com
quantserve.com
rubiconproject.com
scorecardresearch.com
serving-sys.com
sharethrough.com
smartadserver.com
tapad.com
turn.com
yieldmo.com
openx.net
indexww.com
lijit.com
mathtag.com
rlcdn.com
bluekai.com
krxd.net
agkn.com
adzerk.net
contextweb.com
3lift.com
triplelift.com
media.net
medianet.com
admixer.net
revenuecat.com
adcolony.com
vungle.com
unity3d.com
unityads.unity3d.com
applovin.com
# ------------------------------------------------------------
# Outbrain / Taboola / Content Recommendations
# ------------------------------------------------------------
outbrain.com
taboola.com
outbrain-com.cdn.ampproject.org
revcontent.com
mgid.com
zergnet.com
content-recommendation.net
nativo.com
adblade.com
content.ad
# ------------------------------------------------------------
# Popup & Overlay Ad Networks
# ------------------------------------------------------------
popads.net
popcash.net
propellerads.com
propellerpops.com
popunder.net
clickadu.com
hilltopads.net
ad-maven.com
admaven.com
juicyads.com
# ------------------------------------------------------------
# YouTube-Specific Ad Patterns (URL path matches)
# ------------------------------------------------------------
/pagead/
/ptracking
/get_midroll_
/api/stats/ads
/api/stats/atr
/log_interaction
/ad_data_204
/generate_204
/s/player/*/player_ias
/youtubei/v1/log_event?alt=
/youtubei/v1/player/ad_break
# ------------------------------------------------------------
# YouTube Ad Regex Patterns
# ------------------------------------------------------------
/googlevideo\.com\/videoplayback\?.*&ctier=L&/
/googlevideo\.com\/videoplayback\?.*&oad=/
/youtube\.com\/api\/stats\/ads/
/youtube\.com\/pagead\//
/youtube\.com\/ptracking/
/youtube\.com\/get_midroll_/
/doubleclick\.net\/pagead\//
/youtube\.com\/sw\.js_data/
/youtube\.com\/api\/stats\/qoe\?.*adformat/
/youtube\.com\/youtubei\/v1\/log_event\?alt=/
# ------------------------------------------------------------
# YouTube Tracking & Telemetry
# ------------------------------------------------------------
s.youtube.com
video-stats.l.google.com
www.youtube.com/api/stats/
yt3.ggpht.com/a/default-user
# ------------------------------------------------------------
# Facebook / Meta Tracking
# ------------------------------------------------------------
facebook.net
connect.facebook.net
pixel.facebook.com
www.facebook.com/tr
an.facebook.com
staticxx.facebook.com
# ------------------------------------------------------------
# Twitter / X Tracking
# ------------------------------------------------------------
analytics.twitter.com
t.co
ads-twitter.com
ads-api.twitter.com
static.ads-twitter.com
# ------------------------------------------------------------
# Microsoft Tracking
# ------------------------------------------------------------
bat.bing.com
clarity.ms
c.bing.com
c.msn.com
# ------------------------------------------------------------
# Analytics & Session Recording
# ------------------------------------------------------------
hotjar.com
mouseflow.com
fullstory.com
heapanalytics.com
mixpanel.com
segment.com
segment.io
amplitude.com
cdn.amplitude.com
api.amplitude.com
inspectlet.com
luckyorange.com
crazyegg.com
optimizely.com
cdn.optimizely.com
newrelic.com
js-agent.newrelic.com
bam.nr-data.net
sentry.io
# ------------------------------------------------------------
# General Tracking & Fingerprinting
# ------------------------------------------------------------
liadm.com
ipredictive.com
bam-cell.nr-data.net
cdn.ravenjs.com
cdn.mxpnl.com
cdn.heapanalytics.com
static.hotjar.com
script.hotjar.com
vars.hotjar.com
identify.hotjar.com
surveys.hotjar.com
in.hotjar.com
sb.scorecardresearch.com
imrworldwide.com
sb.voicefive.com
c.amazon-adsystem.com
s.amazon-adsystem.com
z-na.amazon-adsystem.com
# ------------------------------------------------------------
# Ad-related CDN / Serving Domains
# ------------------------------------------------------------
cdn.doubleverify.com
tps.doubleverify.com
cdn.adsafeprotected.com
fw.adsafeprotected.com
static.adsafeprotected.com
pixel.adsafeprotected.com
dt.adsafeprotected.com
# ------------------------------------------------------------
# Miscellaneous Ad / Tracking Domains
# ------------------------------------------------------------
adroll.com
d.adroll.com
s.adroll.com
ib.adnxs.com
secure.adnxs.com
acdn.adnxs.com
cdn.adnxs.com
m.adnxs.com
nym1-ib.adnxs.com
bttrack.com
clicktale.net
zemanta.com
teads.tv
adskeeper.co.uk
adf.ly
sh.st
linkbucks.com
bc.vc
ouo.io
shorte.st
adfoc.us
coin-hive.com
coinhive.com
authedmine.com
crypto-loot.com

23
electron/forge.config.js Normal file
View file

@ -0,0 +1,23 @@
const path = require('path');
module.exports = {
packagerConfig: {
name: 'Gaming Hub',
executableName: 'gaming-hub',
icon: path.join(__dirname, 'assets', 'icon'),
asar: true,
},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
name: 'gaming-hub-desktop',
setupIcon: path.join(__dirname, 'assets', 'icon.ico'),
},
},
{
name: '@electron-forge/maker-zip',
platforms: ['win32', 'linux', 'darwin'],
},
],
};

68
electron/main.js Normal file
View file

@ -0,0 +1,68 @@
const { app, BrowserWindow, session, shell } = require('electron');
const path = require('path');
const { setupAdBlocker } = require('./ad-blocker');
// Handle Squirrel events (Windows installer)
try {
if (require('electron-squirrel-startup')) app.quit();
} catch {
// electron-squirrel-startup not installed, skip
}
const HUB_URL = process.env.GAMING_HUB_URL || 'https://hub.daddelolymp.de';
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 900,
minHeight: 600,
title: 'Gaming Hub',
icon: path.join(__dirname, 'assets', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
backgroundColor: '#1a1b1e',
autoHideMenuBar: true,
});
// Setup ad blocker BEFORE loading URL
setupAdBlocker(session.defaultSession);
// Custom User-Agent to identify Electron app
const currentUA = mainWindow.webContents.getUserAgent();
mainWindow.webContents.setUserAgent(currentUA + ' GamingHubDesktop/1.0.0');
mainWindow.loadURL(HUB_URL);
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(HUB_URL)) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
});
// Handle navigation to external URLs
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(HUB_URL)) {
event.preventDefault();
shell.openExternal(url);
}
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

21
electron/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "gaming-hub-desktop",
"productName": "Gaming Hub",
"version": "1.0.0",
"description": "Gaming Hub Desktop App mit Ad-Blocker",
"main": "main.js",
"scripts": {
"start": "electron .",
"package": "electron-forge package",
"make": "electron-forge make"
},
"dependencies": {
"electron-squirrel-startup": "^1.0.1"
},
"devDependencies": {
"electron": "^33.0.0",
"@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-squirrel": "^7.6.0",
"@electron-forge/maker-zip": "^7.6.0"
}
}

6
electron/preload.js Normal file
View file

@ -0,0 +1,6 @@
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
isElectron: true,
version: '1.0.0',
});

View file

@ -10,6 +10,7 @@ import radioPlugin from './plugins/radio/index.js';
import soundboardPlugin from './plugins/soundboard/index.js';
import lolstatsPlugin from './plugins/lolstats/index.js';
import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js';
import watchTogetherPlugin, { attachWatchTogetherWs } from './plugins/watch-together/index.js';
// ── Config ──
const PORT = Number(process.env.PORT ?? 8080);
@ -135,6 +136,11 @@ async function boot(): Promise<void> {
const ctxStreaming: PluginContext = { client: clientStreaming, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
registerPlugin(streamingPlugin, ctxStreaming);
// watch-together has no Discord bot — use a dummy client
const clientWatchTogether = createClient();
const ctxWatchTogether: PluginContext = { client: clientWatchTogether, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
registerPlugin(watchTogetherPlugin, ctxWatchTogether);
// Init all plugins
for (const p of getPlugins()) {
const pCtx = getPluginCtx(p.name)!;
@ -155,6 +161,7 @@ async function boot(): Promise<void> {
// Start Express (http.createServer so WebSocket can attach)
const httpServer = http.createServer(app);
attachWebSocket(httpServer);
attachWatchTogetherWs(httpServer);
httpServer.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
// Login Discord bots

View file

@ -0,0 +1,577 @@
import type express from 'express';
import http from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import crypto from 'node:crypto';
import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js';
// ── Types ──
interface QueueItem {
url: string;
title: string;
addedBy: string;
}
interface RoomMember {
id: string;
name: string;
}
interface Room {
id: string;
name: string;
password: string;
hostId: string;
createdAt: string;
members: Map<string, RoomMember>;
currentVideo: { url: string; title: string } | null;
playing: boolean;
currentTime: number;
lastSyncAt: number;
queue: QueueItem[];
}
interface WtClient {
id: string;
ws: WebSocket;
name: string;
roomId: string | null;
isAlive: boolean;
}
// ── State ──
const rooms = new Map<string, Room>();
const wsClients = new Map<string, WtClient>();
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
let wss: WebSocketServer | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let syncPulseInterval: ReturnType<typeof setInterval> | null = null;
const HEARTBEAT_MS = 5_000;
const SYNC_PULSE_MS = 2_500;
const ROOM_CLEANUP_MS = 30_000;
// ── Helpers ──
function sendTo(client: WtClient, data: Record<string, any>): void {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(data));
}
}
function sendToRoom(roomId: string, data: Record<string, any>, excludeId?: string): void {
const room = rooms.get(roomId);
if (!room) return;
for (const member of room.members.values()) {
if (excludeId && member.id === excludeId) continue;
const client = wsClients.get(member.id);
if (client) sendTo(client, data);
}
}
function getPlaybackState(room: Room): Record<string, any> {
const currentTime = room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0);
return {
type: 'playback_state',
videoUrl: room.currentVideo?.url ?? null,
videoTitle: room.currentVideo?.title ?? null,
playing: room.playing,
currentTime,
updatedAt: Date.now(),
};
}
function serializeRoom(room: Room): Record<string, any> {
return {
id: room.id,
name: room.name,
hostId: room.hostId,
createdAt: room.createdAt,
members: [...room.members.values()],
currentVideo: room.currentVideo,
playing: room.playing,
currentTime: room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0),
queue: room.queue,
};
}
function getRoomList(): Record<string, any>[] {
return [...rooms.values()].map((room) => {
const host = room.members.get(room.hostId);
return {
id: room.id,
name: room.name,
hasPassword: room.password.length > 0,
memberCount: room.members.size,
hostName: host?.name ?? 'Unbekannt',
currentVideo: room.currentVideo,
playing: room.playing,
};
});
}
function broadcastRoomList(): void {
sseBroadcast({ type: 'watch_together_status', plugin: 'watch-together', rooms: getRoomList() });
}
function scheduleRoomCleanup(roomId: string): void {
cancelRoomCleanup(roomId);
const timer = setTimeout(() => {
cleanupTimers.delete(roomId);
const room = rooms.get(roomId);
if (room && room.members.size === 0) {
rooms.delete(roomId);
broadcastRoomList();
console.log(`[WatchTogether] Raum "${room.name}" gelöscht (leer nach Timeout)`);
}
}, ROOM_CLEANUP_MS);
cleanupTimers.set(roomId, timer);
}
function cancelRoomCleanup(roomId: string): void {
const timer = cleanupTimers.get(roomId);
if (timer) {
clearTimeout(timer);
cleanupTimers.delete(roomId);
}
}
function transferHost(room: Room): void {
const nextMember = room.members.values().next().value as RoomMember | undefined;
if (nextMember) {
room.hostId = nextMember.id;
sendToRoom(room.id, { type: 'members_updated', members: [...room.members.values()], hostId: room.hostId });
console.log(`[WatchTogether] Host in "${room.name}" übertragen an ${nextMember.name}`);
} else {
scheduleRoomCleanup(room.id);
}
}
function leaveRoom(client: WtClient): void {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) {
client.roomId = null;
return;
}
room.members.delete(client.id);
const roomId = client.roomId;
client.roomId = null;
if (room.members.size === 0) {
scheduleRoomCleanup(roomId);
} else if (room.hostId === client.id) {
transferHost(room);
} else {
sendToRoom(roomId, { type: 'members_updated', members: [...room.members.values()], hostId: room.hostId });
}
broadcastRoomList();
}
function handleDisconnect(client: WtClient): void {
leaveRoom(client);
}
// ── WebSocket Message Handler ──
function handleMessage(client: WtClient, msg: any): void {
switch (msg.type) {
case 'create_room': {
if (client.roomId) {
sendTo(client, { type: 'error', code: 'ALREADY_IN_ROOM', message: 'Du bist bereits in einem Raum.' });
return;
}
const roomName = String(msg.name || 'Neuer Raum').slice(0, 32);
const password = String(msg.password ?? '').trim();
const userName = String(msg.userName || 'Anon').slice(0, 32);
const roomId = crypto.randomUUID();
client.name = userName;
client.roomId = roomId;
const room: Room = {
id: roomId,
name: roomName,
password,
hostId: client.id,
createdAt: new Date().toISOString(),
members: new Map([[client.id, { id: client.id, name: userName }]]),
currentVideo: null,
playing: false,
currentTime: 0,
lastSyncAt: Date.now(),
queue: [],
};
rooms.set(roomId, room);
sendTo(client, { type: 'room_created', roomId, room: serializeRoom(room) });
broadcastRoomList();
console.log(`[WatchTogether] ${userName} hat Raum "${roomName}" erstellt (${roomId.slice(0, 8)})`);
break;
}
case 'join_room': {
if (client.roomId) {
sendTo(client, { type: 'error', code: 'ALREADY_IN_ROOM', message: 'Du bist bereits in einem Raum. Verlasse zuerst den aktuellen Raum.' });
return;
}
const room = rooms.get(msg.roomId);
if (!room) {
sendTo(client, { type: 'error', code: 'ROOM_NOT_FOUND', message: 'Raum nicht gefunden.' });
return;
}
const joinPw = String(msg.password ?? '').trim();
if (room.password && joinPw !== room.password) {
sendTo(client, { type: 'error', code: 'WRONG_PASSWORD', message: 'Falsches Passwort.' });
return;
}
const userName = String(msg.userName || 'Anon').slice(0, 32);
client.name = userName;
client.roomId = room.id;
room.members.set(client.id, { id: client.id, name: userName });
cancelRoomCleanup(room.id);
sendTo(client, { type: 'room_joined', room: serializeRoom(room) });
sendToRoom(room.id, { type: 'members_updated', members: [...room.members.values()], hostId: room.hostId }, client.id);
broadcastRoomList();
console.log(`[WatchTogether] ${userName} ist Raum "${room.name}" beigetreten`);
break;
}
case 'leave_room': {
leaveRoom(client);
break;
}
case 'add_to_queue': {
if (!client.roomId) {
sendTo(client, { type: 'error', code: 'NOT_IN_ROOM', message: 'Du bist in keinem Raum.' });
return;
}
const room = rooms.get(client.roomId);
if (!room) return;
const url = String(msg.url || '').trim();
if (!url) {
sendTo(client, { type: 'error', code: 'INVALID_URL', message: 'URL darf nicht leer sein.' });
return;
}
const title = String(msg.title || url).slice(0, 128);
room.queue.push({ url, title, addedBy: client.name });
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
// Auto-play first item if nothing is currently playing
if (!room.currentVideo) {
const item = room.queue.shift()!;
room.currentVideo = { url: item.url, title: item.title };
room.playing = true;
room.currentTime = 0;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
}
break;
}
case 'remove_from_queue': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Warteschlange verwalten.' });
return;
}
const index = Number(msg.index);
if (index < 0 || index >= room.queue.length) {
sendTo(client, { type: 'error', code: 'INVALID_INDEX', message: 'Ungültiger Index.' });
return;
}
room.queue.splice(index, 1);
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
break;
}
case 'move_in_queue': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Warteschlange verwalten.' });
return;
}
const from = Number(msg.from);
const to = Number(msg.to);
if (from < 0 || from >= room.queue.length || to < 0 || to >= room.queue.length) {
sendTo(client, { type: 'error', code: 'INVALID_INDEX', message: 'Ungültiger Index.' });
return;
}
const [item] = room.queue.splice(from, 1);
room.queue.splice(to, 0, item);
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
break;
}
case 'play_video': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
return;
}
const index = msg.index != null ? Number(msg.index) : undefined;
if (index !== undefined) {
if (index < 0 || index >= room.queue.length) {
sendTo(client, { type: 'error', code: 'INVALID_INDEX', message: 'Ungültiger Index.' });
return;
}
const [item] = room.queue.splice(index, 1);
room.currentVideo = { url: item.url, title: item.title };
} else {
// No index — play from queue head or error if nothing available
if (!room.currentVideo && room.queue.length === 0) {
sendTo(client, { type: 'error', code: 'QUEUE_EMPTY', message: 'Warteschlange ist leer und kein Video ausgewählt.' });
return;
}
if (!room.currentVideo && room.queue.length > 0) {
const item = room.queue.shift()!;
room.currentVideo = { url: item.url, title: item.title };
}
}
room.playing = true;
room.currentTime = 0;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
break;
}
case 'pause': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
return;
}
// Calculate drift before pausing
room.currentTime = room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0);
room.playing = false;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
break;
}
case 'resume': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
return;
}
room.playing = true;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
break;
}
case 'seek': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
return;
}
room.currentTime = Number(msg.time) || 0;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
break;
}
case 'skip': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
return;
}
if (room.queue.length > 0) {
const item = room.queue.shift()!;
room.currentVideo = { url: item.url, title: item.title };
} else {
room.currentVideo = null;
}
room.playing = room.currentVideo !== null;
room.currentTime = 0;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
break;
}
case 'report_time': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) return;
room.currentTime = Number(msg.time) || 0;
room.lastSyncAt = Date.now();
break;
}
case 'transfer_host': {
if (!client.roomId) return;
const room = rooms.get(client.roomId);
if (!room) return;
if (room.hostId !== client.id) {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann den Host übertragen.' });
return;
}
const targetId = String(msg.userId || '');
if (!room.members.has(targetId)) {
sendTo(client, { type: 'error', code: 'USER_NOT_FOUND', message: 'Benutzer nicht im Raum gefunden.' });
return;
}
room.hostId = targetId;
sendToRoom(room.id, { type: 'members_updated', members: [...room.members.values()], hostId: room.hostId });
const targetMember = room.members.get(targetId);
console.log(`[WatchTogether] Host in "${room.name}" übertragen an ${targetMember?.name ?? targetId.slice(0, 8)}`);
break;
}
default:
break;
}
}
// ── Plugin ──
const watchTogetherPlugin: Plugin = {
name: 'watch-together',
version: '1.0.0',
description: 'Watch Together',
async init(_ctx) {
console.log('[WatchTogether] Initialized');
},
registerRoutes(app: express.Application, _ctx: PluginContext) {
app.get('/api/watch-together/rooms', (_req, res) => {
res.json({ rooms: getRoomList() });
});
// Beacon cleanup endpoint — called via navigator.sendBeacon() on page unload
app.post('/api/watch-together/disconnect', (req, res) => {
let body = '';
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
req.on('end', () => {
try {
const { clientId } = JSON.parse(body);
const client = wsClients.get(clientId);
if (client) {
console.log(`[WatchTogether] Beacon disconnect für ${client.name || clientId.slice(0, 8)}`);
handleDisconnect(client);
wsClients.delete(clientId);
client.ws.terminate();
}
} catch { /* ignore malformed */ }
res.status(204).end();
});
});
},
getSnapshot(_ctx) {
return {
'watch-together': { rooms: getRoomList() },
};
},
async destroy() {
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
if (syncPulseInterval) { clearInterval(syncPulseInterval); syncPulseInterval = null; }
for (const timer of cleanupTimers.values()) { clearTimeout(timer); }
cleanupTimers.clear();
if (wss) {
for (const client of wsClients.values()) {
client.ws.close(1001, 'Server shutting down');
}
wsClients.clear();
rooms.clear();
wss.close();
wss = null;
}
console.log('[WatchTogether] Destroyed');
},
};
/** Call after httpServer is created to attach WebSocket */
export function attachWatchTogetherWs(server: http.Server): void {
wss = new WebSocketServer({ server, path: '/ws/watch-together' });
wss.on('connection', (ws) => {
const clientId = crypto.randomUUID();
const client: WtClient = { id: clientId, ws, name: '', roomId: null, isAlive: true };
wsClients.set(clientId, client);
sendTo(client, { type: 'welcome', clientId, rooms: getRoomList() });
ws.on('pong', () => { client.isAlive = true; });
ws.on('message', (raw) => {
client.isAlive = true;
let msg: any;
try { msg = JSON.parse(raw.toString()); } catch { return; }
handleMessage(client, msg);
});
ws.on('close', () => {
handleDisconnect(client);
wsClients.delete(clientId);
});
ws.on('error', () => {
handleDisconnect(client);
wsClients.delete(clientId);
});
});
// ── Heartbeat: detect dead connections ──
heartbeatInterval = setInterval(() => {
for (const [id, client] of wsClients) {
if (!client.isAlive) {
console.log(`[WatchTogether] Heartbeat timeout für ${client.name || id.slice(0, 8)}`);
handleDisconnect(client);
wsClients.delete(id);
client.ws.terminate();
continue;
}
client.isAlive = false;
try { client.ws.ping(); } catch { /* ignore */ }
}
}, HEARTBEAT_MS);
// ── Sync pulse: broadcast playback state to playing rooms ──
syncPulseInterval = setInterval(() => {
for (const room of rooms.values()) {
if (room.playing && room.members.size > 0) {
sendToRoom(room.id, getPlaybackState(room));
}
}
}, SYNC_PULSE_MS);
console.log('[WatchTogether] WebSocket attached at /ws/watch-together');
}
export default watchTogetherPlugin;

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