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
87
electron/ad-blocker.js
Normal file
87
electron/ad-blocker.js
Normal 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
253
electron/filters.txt
Normal 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
23
electron/forge.config.js
Normal 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
68
electron/main.js
Normal 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
21
electron/package.json
Normal 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
6
electron/preload.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
const { contextBridge } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
isElectron: true,
|
||||||
|
version: '1.0.0',
|
||||||
|
});
|
||||||
|
|
@ -10,6 +10,7 @@ import radioPlugin from './plugins/radio/index.js';
|
||||||
import soundboardPlugin from './plugins/soundboard/index.js';
|
import soundboardPlugin from './plugins/soundboard/index.js';
|
||||||
import lolstatsPlugin from './plugins/lolstats/index.js';
|
import lolstatsPlugin from './plugins/lolstats/index.js';
|
||||||
import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js';
|
import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js';
|
||||||
|
import watchTogetherPlugin, { attachWatchTogetherWs } from './plugins/watch-together/index.js';
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT ?? 8080);
|
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 };
|
const ctxStreaming: PluginContext = { client: clientStreaming, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS };
|
||||||
registerPlugin(streamingPlugin, ctxStreaming);
|
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
|
// Init all plugins
|
||||||
for (const p of getPlugins()) {
|
for (const p of getPlugins()) {
|
||||||
const pCtx = getPluginCtx(p.name)!;
|
const pCtx = getPluginCtx(p.name)!;
|
||||||
|
|
@ -155,6 +161,7 @@ async function boot(): Promise<void> {
|
||||||
// Start Express (http.createServer so WebSocket can attach)
|
// Start Express (http.createServer so WebSocket can attach)
|
||||||
const httpServer = http.createServer(app);
|
const httpServer = http.createServer(app);
|
||||||
attachWebSocket(httpServer);
|
attachWebSocket(httpServer);
|
||||||
|
attachWatchTogetherWs(httpServer);
|
||||||
httpServer.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
|
httpServer.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
|
||||||
|
|
||||||
// Login Discord bots
|
// Login Discord bots
|
||||||
|
|
|
||||||
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;
|
||||||
File diff suppressed because one or more lines are too long
4830
web/dist/assets/index-UqZEdiQO.js
vendored
4830
web/dist/assets/index-UqZEdiQO.js
vendored
File diff suppressed because one or more lines are too long
4830
web/dist/assets/index-ZMOZU_VE.js
vendored
Normal file
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
4
web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gaming Hub</title>
|
<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>" />
|
<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>
|
<script type="module" crossorigin src="/assets/index-ZMOZU_VE.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DKX7sma7.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C2eno-Si.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import RadioTab from './plugins/radio/RadioTab';
|
||||||
import SoundboardTab from './plugins/soundboard/SoundboardTab';
|
import SoundboardTab from './plugins/soundboard/SoundboardTab';
|
||||||
import LolstatsTab from './plugins/lolstats/LolstatsTab';
|
import LolstatsTab from './plugins/lolstats/LolstatsTab';
|
||||||
import StreamingTab from './plugins/streaming/StreamingTab';
|
import StreamingTab from './plugins/streaming/StreamingTab';
|
||||||
|
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
|
||||||
|
|
||||||
interface PluginInfo {
|
interface PluginInfo {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -16,6 +17,7 @@ const tabComponents: Record<string, React.FC<{ data: any }>> = {
|
||||||
soundboard: SoundboardTab,
|
soundboard: SoundboardTab,
|
||||||
lolstats: LolstatsTab,
|
lolstats: LolstatsTab,
|
||||||
streaming: StreamingTab,
|
streaming: StreamingTab,
|
||||||
|
'watch-together': WatchTogetherTab,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
|
export function registerTab(pluginName: string, component: React.FC<{ data: any }>) {
|
||||||
|
|
@ -101,6 +103,7 @@ export default function App() {
|
||||||
games: '\u{1F3B2}',
|
games: '\u{1F3B2}',
|
||||||
gamevote: '\u{1F3AE}',
|
gamevote: '\u{1F3AE}',
|
||||||
streaming: '\u{1F4FA}',
|
streaming: '\u{1F4FA}',
|
||||||
|
'watch-together': '\u{1F3AC}',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -127,6 +130,15 @@ export default function App() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hub-header-right">
|
<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>
|
<span className="hub-version">v{version}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
758
web/src/plugins/watch-together/WatchTogetherTab.tsx
Normal file
758
web/src/plugins/watch-together/WatchTogetherTab.tsx
Normal file
|
|
@ -0,0 +1,758 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import './watch-together.css';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface RoomInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostName: string;
|
||||||
|
memberCount: number;
|
||||||
|
hasPassword: boolean;
|
||||||
|
playing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomState {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostId: string;
|
||||||
|
members: Array<{ id: string; name: string }>;
|
||||||
|
currentVideo: { url: string; title: string } | null;
|
||||||
|
playing: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
queue: Array<{ url: string; title: string; addedBy: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinModal {
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
password: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function formatTime(s: number): string {
|
||||||
|
const sec = Math.floor(s);
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const ss = sec % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(ss).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVideoUrl(url: string): { type: 'youtube'; videoId: string } | { type: 'direct'; url: string } | null {
|
||||||
|
const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||||
|
if (ytMatch) return { type: 'youtube', videoId: ytMatch[1] };
|
||||||
|
if (/\.(mp4|webm|ogg)(\?|$)/i.test(url) || url.startsWith('http')) return { type: 'direct', url };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
YT: any;
|
||||||
|
onYouTubeIframeAPIReady: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
|
// ── State ──
|
||||||
|
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||||
|
const [userName, setUserName] = useState(() => localStorage.getItem('wt_name') || '');
|
||||||
|
const [roomName, setRoomName] = useState('');
|
||||||
|
const [roomPassword, setRoomPassword] = useState('');
|
||||||
|
const [currentRoom, setCurrentRoom] = useState<RoomState | null>(null);
|
||||||
|
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [queueUrl, setQueueUrl] = useState('');
|
||||||
|
const [volume, setVolume] = useState(() => {
|
||||||
|
const v = localStorage.getItem('wt_volume');
|
||||||
|
return v ? parseFloat(v) : 1;
|
||||||
|
});
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
|
// ── Refs ──
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const clientIdRef = useRef<string>('');
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectDelayRef = useRef(1000);
|
||||||
|
const currentRoomRef = useRef<RoomState | null>(null);
|
||||||
|
const roomContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Player refs
|
||||||
|
const ytPlayerRef = useRef<any>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const playerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const currentVideoTypeRef = useRef<'youtube' | 'direct' | null>(null);
|
||||||
|
const ytReadyRef = useRef(false);
|
||||||
|
const seekingRef = useRef(false);
|
||||||
|
const timeUpdateRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Mirror state to refs
|
||||||
|
useEffect(() => { currentRoomRef.current = currentRoom; }, [currentRoom]);
|
||||||
|
|
||||||
|
const isHost = currentRoom != null && clientIdRef.current === currentRoom.hostId;
|
||||||
|
|
||||||
|
// ── SSE data ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.rooms) {
|
||||||
|
setRooms(data.rooms);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// ── Save name ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (userName) localStorage.setItem('wt_name', userName);
|
||||||
|
}, [userName]);
|
||||||
|
|
||||||
|
// ── Save volume ──
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('wt_volume', String(volume));
|
||||||
|
if (ytPlayerRef.current && typeof ytPlayerRef.current.setVolume === 'function') {
|
||||||
|
ytPlayerRef.current.setVolume(volume * 100);
|
||||||
|
}
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
// ── YouTube IFrame API ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.YT) { ytReadyRef.current = true; return; }
|
||||||
|
const tag = document.createElement('script');
|
||||||
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
const prev = window.onYouTubeIframeAPIReady;
|
||||||
|
window.onYouTubeIframeAPIReady = () => {
|
||||||
|
ytReadyRef.current = true;
|
||||||
|
if (prev) prev();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── WS send ──
|
||||||
|
const wsSend = useCallback((d: Record<string, any>) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(d));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Get current time from active player ──
|
||||||
|
const getCurrentTime = useCallback((): number | null => {
|
||||||
|
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current && typeof ytPlayerRef.current.getCurrentTime === 'function') {
|
||||||
|
return ytPlayerRef.current.getCurrentTime();
|
||||||
|
}
|
||||||
|
if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
|
||||||
|
return videoRef.current.currentTime;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Get duration from active player ──
|
||||||
|
const getDuration = useCallback((): number => {
|
||||||
|
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current && typeof ytPlayerRef.current.getDuration === 'function') {
|
||||||
|
return ytPlayerRef.current.getDuration() || 0;
|
||||||
|
}
|
||||||
|
if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
|
||||||
|
return videoRef.current.duration || 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Destroy player ──
|
||||||
|
const destroyPlayer = useCallback(() => {
|
||||||
|
if (ytPlayerRef.current) {
|
||||||
|
try { ytPlayerRef.current.destroy(); } catch {}
|
||||||
|
ytPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
videoRef.current.removeAttribute('src');
|
||||||
|
videoRef.current.load();
|
||||||
|
}
|
||||||
|
currentVideoTypeRef.current = null;
|
||||||
|
if (timeUpdateRef.current) {
|
||||||
|
clearInterval(timeUpdateRef.current);
|
||||||
|
timeUpdateRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Load video ──
|
||||||
|
const loadVideo = useCallback((url: string) => {
|
||||||
|
destroyPlayer();
|
||||||
|
const parsed = parseVideoUrl(url);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
if (parsed.type === 'youtube') {
|
||||||
|
currentVideoTypeRef.current = 'youtube';
|
||||||
|
if (!ytReadyRef.current || !playerContainerRef.current) return;
|
||||||
|
|
||||||
|
// Create a fresh div for YT player
|
||||||
|
const container = playerContainerRef.current;
|
||||||
|
const ytDiv = document.createElement('div');
|
||||||
|
ytDiv.id = 'wt-yt-player-' + Date.now();
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(ytDiv);
|
||||||
|
|
||||||
|
ytPlayerRef.current = new window.YT.Player(ytDiv.id, {
|
||||||
|
videoId: parsed.videoId,
|
||||||
|
playerVars: { autoplay: 1, controls: 0, modestbranding: 1, rel: 0 },
|
||||||
|
events: {
|
||||||
|
onReady: (ev: any) => {
|
||||||
|
ev.target.setVolume(volume * 100);
|
||||||
|
setDuration(ev.target.getDuration() || 0);
|
||||||
|
// Time update interval
|
||||||
|
timeUpdateRef.current = setInterval(() => {
|
||||||
|
if (ytPlayerRef.current && typeof ytPlayerRef.current.getCurrentTime === 'function') {
|
||||||
|
setCurrentTime(ytPlayerRef.current.getCurrentTime());
|
||||||
|
setDuration(ytPlayerRef.current.getDuration() || 0);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
onStateChange: (ev: any) => {
|
||||||
|
if (ev.data === window.YT.PlayerState.ENDED) {
|
||||||
|
const room = currentRoomRef.current;
|
||||||
|
if (room && clientIdRef.current === room.hostId) {
|
||||||
|
wsSend({ type: 'skip' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentVideoTypeRef.current = 'direct';
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.src = parsed.url;
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
videoRef.current.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [destroyPlayer, volume, wsSend]);
|
||||||
|
|
||||||
|
// ── HTML5 video ended handler ──
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const onEnded = () => {
|
||||||
|
const room = currentRoomRef.current;
|
||||||
|
if (room && clientIdRef.current === room.hostId) {
|
||||||
|
wsSend({ type: 'skip' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
setDuration(video.duration || 0);
|
||||||
|
};
|
||||||
|
video.addEventListener('ended', onEnded);
|
||||||
|
video.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('ended', onEnded);
|
||||||
|
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
|
};
|
||||||
|
}, [wsSend]);
|
||||||
|
|
||||||
|
// ── WS message handler ──
|
||||||
|
const handleWsMessageRef = useRef<(msg: any) => void>(() => {});
|
||||||
|
handleWsMessageRef.current = (msg: any) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'welcome':
|
||||||
|
clientIdRef.current = msg.clientId;
|
||||||
|
if (msg.rooms) setRooms(msg.rooms);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'room_created':
|
||||||
|
setCurrentRoom({
|
||||||
|
id: msg.roomId,
|
||||||
|
name: msg.name,
|
||||||
|
hostId: msg.hostId,
|
||||||
|
members: msg.members || [],
|
||||||
|
currentVideo: null,
|
||||||
|
playing: false,
|
||||||
|
currentTime: 0,
|
||||||
|
queue: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'room_joined':
|
||||||
|
setCurrentRoom({
|
||||||
|
id: msg.roomId,
|
||||||
|
name: msg.name,
|
||||||
|
hostId: msg.hostId,
|
||||||
|
members: msg.members || [],
|
||||||
|
currentVideo: msg.currentVideo || null,
|
||||||
|
playing: msg.playing || false,
|
||||||
|
currentTime: msg.currentTime || 0,
|
||||||
|
queue: msg.queue || [],
|
||||||
|
});
|
||||||
|
// Load video if one is playing
|
||||||
|
if (msg.currentVideo?.url) {
|
||||||
|
setTimeout(() => loadVideo(msg.currentVideo.url), 100);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'playback_state': {
|
||||||
|
const room = currentRoomRef.current;
|
||||||
|
if (!room) break;
|
||||||
|
|
||||||
|
const newVideo = msg.currentVideo;
|
||||||
|
const prevUrl = room.currentVideo?.url;
|
||||||
|
|
||||||
|
// 1. Video URL changed → load new video
|
||||||
|
if (newVideo?.url && newVideo.url !== prevUrl) {
|
||||||
|
loadVideo(newVideo.url);
|
||||||
|
} else if (!newVideo && prevUrl) {
|
||||||
|
destroyPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Playing mismatch → play or pause
|
||||||
|
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current) {
|
||||||
|
const playerState = ytPlayerRef.current.getPlayerState?.();
|
||||||
|
if (msg.playing && playerState !== window.YT?.PlayerState?.PLAYING) {
|
||||||
|
ytPlayerRef.current.playVideo?.();
|
||||||
|
} else if (!msg.playing && playerState === window.YT?.PlayerState?.PLAYING) {
|
||||||
|
ytPlayerRef.current.pauseVideo?.();
|
||||||
|
}
|
||||||
|
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
|
||||||
|
if (msg.playing && videoRef.current.paused) {
|
||||||
|
videoRef.current.play().catch(() => {});
|
||||||
|
} else if (!msg.playing && !videoRef.current.paused) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Drift correction: if |localTime - serverTime| > 2 → seek
|
||||||
|
if (msg.currentTime !== undefined && !seekingRef.current) {
|
||||||
|
const localTime = getCurrentTime();
|
||||||
|
if (localTime !== null && Math.abs(localTime - msg.currentTime) > 2) {
|
||||||
|
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current) {
|
||||||
|
ytPlayerRef.current.seekTo?.(msg.currentTime, true);
|
||||||
|
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
|
||||||
|
videoRef.current.currentTime = msg.currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentRoom(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
currentVideo: newVideo || null,
|
||||||
|
playing: msg.playing,
|
||||||
|
currentTime: msg.currentTime ?? prev.currentTime,
|
||||||
|
} : prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'queue_updated':
|
||||||
|
setCurrentRoom(prev => prev ? { ...prev, queue: msg.queue } : prev);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'members_updated':
|
||||||
|
setCurrentRoom(prev => prev ? { ...prev, members: msg.members, hostId: msg.hostId } : prev);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
if (msg.code === 'WRONG_PASSWORD') {
|
||||||
|
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
|
||||||
|
} else {
|
||||||
|
setError(msg.message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── WebSocket connect ──
|
||||||
|
const connectWs = useCallback(() => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/ws/watch-together`);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => { reconnectDelayRef.current = 1000; };
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: any;
|
||||||
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||||
|
handleWsMessageRef.current(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
if (currentRoomRef.current) {
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
|
||||||
|
connectWs();
|
||||||
|
}, reconnectDelayRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { ws.close(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Create room ──
|
||||||
|
const createRoom = useCallback(() => {
|
||||||
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
||||||
|
if (!roomName.trim()) { setError('Bitte gib einen Raumnamen ein.'); return; }
|
||||||
|
setError(null);
|
||||||
|
connectWs();
|
||||||
|
|
||||||
|
const waitForWs = () => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsSend({
|
||||||
|
type: 'create_room',
|
||||||
|
name: userName.trim(),
|
||||||
|
roomName: roomName.trim(),
|
||||||
|
password: roomPassword.trim() || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(waitForWs, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForWs();
|
||||||
|
}, [userName, roomName, roomPassword, connectWs, wsSend]);
|
||||||
|
|
||||||
|
// ── Join room ──
|
||||||
|
const joinRoom = useCallback((roomId: string, password?: string) => {
|
||||||
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
||||||
|
setError(null);
|
||||||
|
connectWs();
|
||||||
|
|
||||||
|
const waitForWs = () => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsSend({
|
||||||
|
type: 'join_room',
|
||||||
|
name: userName.trim(),
|
||||||
|
roomId,
|
||||||
|
password: password?.trim() || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(waitForWs, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForWs();
|
||||||
|
}, [userName, connectWs, wsSend]);
|
||||||
|
|
||||||
|
// ── Leave room ──
|
||||||
|
const leaveRoom = useCallback(() => {
|
||||||
|
wsSend({ type: 'leave_room' });
|
||||||
|
destroyPlayer();
|
||||||
|
setCurrentRoom(null);
|
||||||
|
setQueueUrl('');
|
||||||
|
setDuration(0);
|
||||||
|
setCurrentTime(0);
|
||||||
|
}, [wsSend, destroyPlayer]);
|
||||||
|
|
||||||
|
// ── Add to queue ──
|
||||||
|
const addToQueue = useCallback(() => {
|
||||||
|
if (!queueUrl.trim()) return;
|
||||||
|
wsSend({ type: 'add_to_queue', url: queueUrl.trim() });
|
||||||
|
setQueueUrl('');
|
||||||
|
}, [queueUrl, wsSend]);
|
||||||
|
|
||||||
|
// ── Remove from queue ──
|
||||||
|
const removeFromQueue = useCallback((index: number) => {
|
||||||
|
wsSend({ type: 'remove_from_queue', index });
|
||||||
|
}, [wsSend]);
|
||||||
|
|
||||||
|
// ── Playback controls (host only) ──
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
wsSend({ type: 'toggle_play' });
|
||||||
|
}, [wsSend]);
|
||||||
|
|
||||||
|
const skip = useCallback(() => {
|
||||||
|
wsSend({ type: 'skip' });
|
||||||
|
}, [wsSend]);
|
||||||
|
|
||||||
|
const seek = useCallback((time: number) => {
|
||||||
|
seekingRef.current = true;
|
||||||
|
wsSend({ type: 'seek', time });
|
||||||
|
// Seek locally immediately
|
||||||
|
if (currentVideoTypeRef.current === 'youtube' && ytPlayerRef.current) {
|
||||||
|
ytPlayerRef.current.seekTo?.(time, true);
|
||||||
|
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
|
||||||
|
videoRef.current.currentTime = time;
|
||||||
|
}
|
||||||
|
setCurrentTime(time);
|
||||||
|
setTimeout(() => { seekingRef.current = false; }, 1000);
|
||||||
|
}, [wsSend]);
|
||||||
|
|
||||||
|
// ── Host time reporting ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHost || !currentRoom?.playing) return;
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
const time = getCurrentTime();
|
||||||
|
if (time !== null) wsSend({ type: 'report_time', time });
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, [isHost, currentRoom?.playing, wsSend, getCurrentTime]);
|
||||||
|
|
||||||
|
// ── Fullscreen ──
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
const el = roomContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!document.fullscreenElement) el.requestFullscreen().catch(() => {});
|
||||||
|
else document.exitFullscreen().catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', handler);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── beforeunload + pagehide ──
|
||||||
|
useEffect(() => {
|
||||||
|
const beforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (currentRoomRef.current) e.preventDefault();
|
||||||
|
};
|
||||||
|
const pageHide = () => {
|
||||||
|
if (clientIdRef.current) {
|
||||||
|
navigator.sendBeacon('/api/watch-together/disconnect', JSON.stringify({ clientId: clientIdRef.current }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', beforeUnload);
|
||||||
|
window.addEventListener('pagehide', pageHide);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload);
|
||||||
|
window.removeEventListener('pagehide', pageHide);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Cleanup on unmount ──
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
destroyPlayer();
|
||||||
|
if (wsRef.current) wsRef.current.close();
|
||||||
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [destroyPlayer]);
|
||||||
|
|
||||||
|
// ── Join modal submit ──
|
||||||
|
const submitJoinModal = useCallback(() => {
|
||||||
|
if (!joinModal) return;
|
||||||
|
if (!joinModal.password.trim()) {
|
||||||
|
setJoinModal(prev => prev ? { ...prev, error: 'Passwort eingeben.' } : prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { roomId, password } = joinModal;
|
||||||
|
setJoinModal(null);
|
||||||
|
joinRoom(roomId, password);
|
||||||
|
}, [joinModal, joinRoom]);
|
||||||
|
|
||||||
|
// ── Tile click ──
|
||||||
|
const handleTileClick = useCallback((room: RoomInfo) => {
|
||||||
|
if (room.hasPassword) {
|
||||||
|
setJoinModal({ roomId: room.id, roomName: room.name, password: '', error: null });
|
||||||
|
} else {
|
||||||
|
joinRoom(room.id);
|
||||||
|
}
|
||||||
|
}, [joinRoom]);
|
||||||
|
|
||||||
|
// ── Render: Room View ──
|
||||||
|
if (currentRoom) {
|
||||||
|
const hostMember = currentRoom.members.find(m => m.id === currentRoom.hostId);
|
||||||
|
return (
|
||||||
|
<div className="wt-room-overlay" ref={roomContainerRef}>
|
||||||
|
<div className="wt-room-header">
|
||||||
|
<div className="wt-room-header-left">
|
||||||
|
<span className="wt-room-name">{currentRoom.name}</span>
|
||||||
|
<span className="wt-room-members">{currentRoom.members.length} Mitglieder</span>
|
||||||
|
{hostMember && <span className="wt-host-badge">Host: {hostMember.name}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="wt-room-header-right">
|
||||||
|
<button className="wt-fullscreen-btn" onClick={toggleFullscreen} title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}>
|
||||||
|
{isFullscreen ? '\u2716' : '\u26F6'}
|
||||||
|
</button>
|
||||||
|
<button className="wt-leave-btn" onClick={leaveRoom}>Verlassen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wt-room-body">
|
||||||
|
<div className="wt-player-section">
|
||||||
|
<div className="wt-player-wrap">
|
||||||
|
<div ref={playerContainerRef} className="wt-yt-container" style={currentVideoTypeRef.current === 'youtube' ? {} : { display: 'none' }} />
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="wt-video-element"
|
||||||
|
style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
{!currentRoom.currentVideo && (
|
||||||
|
<div className="wt-player-placeholder">
|
||||||
|
<div className="wt-placeholder-icon">{'\uD83C\uDFAC'}</div>
|
||||||
|
<p>Fuege ein Video zur Warteschlange hinzu</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wt-controls">
|
||||||
|
{isHost ? (
|
||||||
|
<>
|
||||||
|
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
|
||||||
|
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
||||||
|
</button>
|
||||||
|
<button className="wt-ctrl-btn" onClick={skip} disabled={!currentRoom.currentVideo} title="Weiter">
|
||||||
|
{'\u23ED'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="wt-seek"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step={0.5}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={e => seek(parseFloat(e.target.value))}
|
||||||
|
disabled={!currentRoom.currentVideo}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="wt-ctrl-status">{currentRoom.playing ? '\u25B6' : '\u23F8'}</span>
|
||||||
|
<div className="wt-seek-readonly">
|
||||||
|
<div className="wt-seek-progress" style={{ width: duration > 0 ? `${(currentTime / duration) * 100}%` : '0%' }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="wt-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||||
|
<div className="wt-volume">
|
||||||
|
<span className="wt-volume-icon">{volume === 0 ? '\uD83D\uDD07' : volume < 0.5 ? '\uD83D\uDD09' : '\uD83D\uDD0A'}</span>
|
||||||
|
<input
|
||||||
|
className="wt-volume-slider"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={e => setVolume(parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wt-queue-panel">
|
||||||
|
<div className="wt-queue-header">Warteschlange ({currentRoom.queue.length})</div>
|
||||||
|
<div className="wt-queue-list">
|
||||||
|
{currentRoom.queue.length === 0 ? (
|
||||||
|
<div className="wt-queue-empty">Keine Videos in der Warteschlange</div>
|
||||||
|
) : (
|
||||||
|
currentRoom.queue.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`wt-queue-item${currentRoom.currentVideo?.url === item.url ? ' playing' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="wt-queue-item-info">
|
||||||
|
<div className="wt-queue-item-title">{item.title || item.url}</div>
|
||||||
|
<div className="wt-queue-item-by">{item.addedBy}</div>
|
||||||
|
</div>
|
||||||
|
{isHost && (
|
||||||
|
<button className="wt-queue-item-remove" onClick={() => removeFromQueue(i)} title="Entfernen">
|
||||||
|
{'\u00D7'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="wt-queue-add">
|
||||||
|
<input
|
||||||
|
className="wt-input wt-queue-input"
|
||||||
|
placeholder="Video-URL eingeben"
|
||||||
|
value={queueUrl}
|
||||||
|
onChange={e => setQueueUrl(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') addToQueue(); }}
|
||||||
|
/>
|
||||||
|
<button className="wt-btn wt-queue-add-btn" onClick={addToQueue}>Hinzufuegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: Lobby ──
|
||||||
|
return (
|
||||||
|
<div className="wt-container">
|
||||||
|
{error && (
|
||||||
|
<div className="wt-error">
|
||||||
|
{error}
|
||||||
|
<button className="wt-error-dismiss" onClick={() => setError(null)}>{'\u00D7'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wt-topbar">
|
||||||
|
<input
|
||||||
|
className="wt-input wt-input-name"
|
||||||
|
placeholder="Dein Name"
|
||||||
|
value={userName}
|
||||||
|
onChange={e => setUserName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="wt-input wt-input-room"
|
||||||
|
placeholder="Raumname"
|
||||||
|
value={roomName}
|
||||||
|
onChange={e => setRoomName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="wt-input wt-input-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Passwort (optional)"
|
||||||
|
value={roomPassword}
|
||||||
|
onChange={e => setRoomPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="wt-btn" onClick={createRoom}>Raum erstellen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rooms.length === 0 ? (
|
||||||
|
<div className="wt-empty">
|
||||||
|
<div className="wt-empty-icon">{'\uD83C\uDFAC'}</div>
|
||||||
|
<h3>Keine aktiven Raeume</h3>
|
||||||
|
<p>Erstelle einen Raum, um gemeinsam Videos zu schauen.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="wt-grid">
|
||||||
|
{rooms.map(room => (
|
||||||
|
<div key={room.id} className="wt-tile" onClick={() => handleTileClick(room)}>
|
||||||
|
<div className="wt-tile-preview">
|
||||||
|
<span className="wt-tile-icon">{'\uD83C\uDFAC'}</span>
|
||||||
|
<span className="wt-tile-members">{'\uD83D\uDC65'} {room.memberCount}</span>
|
||||||
|
{room.hasPassword && <span className="wt-tile-lock">{'\uD83D\uDD12'}</span>}
|
||||||
|
{room.playing && <span className="wt-tile-playing">{'\u25B6'}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="wt-tile-info">
|
||||||
|
<div className="wt-tile-meta">
|
||||||
|
<div className="wt-tile-name">{room.name}</div>
|
||||||
|
<div className="wt-tile-host">{room.hostName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{joinModal && (
|
||||||
|
<div className="wt-modal-overlay" onClick={() => setJoinModal(null)}>
|
||||||
|
<div className="wt-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>{joinModal.roomName}</h3>
|
||||||
|
<p>Raum-Passwort</p>
|
||||||
|
{joinModal.error && <div className="wt-modal-error">{joinModal.error}</div>}
|
||||||
|
<input
|
||||||
|
className="wt-input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Passwort"
|
||||||
|
value={joinModal.password}
|
||||||
|
onChange={e => setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="wt-modal-actions">
|
||||||
|
<button className="wt-modal-cancel" onClick={() => setJoinModal(null)}>Abbrechen</button>
|
||||||
|
<button className="wt-btn" onClick={submitJoinModal}>Beitreten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
730
web/src/plugins/watch-together/watch-together.css
Normal file
730
web/src/plugins/watch-together/watch-together.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,17 @@ html, body {
|
||||||
flex-shrink: 0;
|
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 {
|
.hub-version {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue