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