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

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;