Overlay: fuenf Browser-Sources fuer OBS
Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein Ding, das mitten im Stream kaputtgehen kann. Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md: Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource` verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge: der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar), und ein Zustands-Schnappschuss beim Verbinden. Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur ueber EventSub. GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten. gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif PRIVMSG #abdulive_ :[Idk GIF by Respective] Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn. Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]" im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094 Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse eben nicht im Text steht. Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind: - Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der Ausblend-Animation aus dem DOM verschwinden, und lief in eine Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag eingefroren. - Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe gueltig aus und wuerde den falschen Textteil ersetzen. Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht, sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus. build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket 404 statt Overlay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b606e36fb
commit
bc2c8f6cbe
24 files changed
+3221
-8
No files matched your search
@@ -0,0 +1,627 @@
|
||||
// Der einzige Server, der von aussen erreichbar ist.
|
||||
//
|
||||
// Er macht drei Dinge unter einer Adresse, weil ein zweiter oeffentlicher
|
||||
// Port ein zweiter Eintrag im Reverse-Proxy und ein zweites Zertifikat waere:
|
||||
//
|
||||
// / Uebersicht mit den Adressen fuer OBS (Passwort)
|
||||
// /setup Einrichtung: Twitch-Apps, Tokens, Discord (Passwort)
|
||||
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
|
||||
// /alerts ... die Browser-Sources (Schluessel)
|
||||
// /events der SSE-Strom dahinter (Schluessel)
|
||||
//
|
||||
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
|
||||
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
|
||||
// ADR-0005, und der Schutz wandert dafuer von "nur localhost" auf Passwort
|
||||
// und Schluessel. Ohne PUBLIC_URL bleibt es bei 127.0.0.1.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||
import { createZugang, type Zugang } from './auth.ts';
|
||||
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||
import { startDemo } from './demo.ts';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay');
|
||||
|
||||
const WEB_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'overlay-web', 'public');
|
||||
|
||||
/**
|
||||
* Herzschlag. OBS' Browser-Source haelt eine tote Verbindung sonst fuer
|
||||
* lebendig, bis die naechste Chatnachricht kommt - und wenn der Chat still
|
||||
* ist, ist das eine Stunde.
|
||||
*/
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
|
||||
export const PAGES = [
|
||||
{ path: '/alerts', file: 'alerts.html', w: 1920, h: 1080,
|
||||
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
||||
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
||||
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||
title: 'Effekte', hint: 'Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids.' },
|
||||
{ path: '/starting', file: 'starting.html', w: 1920, h: 1080,
|
||||
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
|
||||
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
|
||||
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
|
||||
] as const;
|
||||
|
||||
export type OverlayServer = {
|
||||
bus: Bus;
|
||||
zugang: Zugang;
|
||||
close: () => void;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type OverlayOptions = {
|
||||
port: number;
|
||||
channel: string;
|
||||
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
|
||||
publicUrl: string;
|
||||
bus?: Bus;
|
||||
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
|
||||
setup?: ServerDeps;
|
||||
/**
|
||||
* Die Freigabe wartender Vorlese-Nachrichten. Der Hub reicht sie herein;
|
||||
* ohne sie zeigt die Seite den Abschnitt gar nicht erst an.
|
||||
*/
|
||||
tts?: {
|
||||
wartend: () => Array<{ id: string; name: string; text: string; seit: string }>;
|
||||
freigeben: (id: string) => boolean;
|
||||
ablehnen: (id: string) => boolean;
|
||||
alleAblehnen: () => number;
|
||||
still: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
const bus = opts.bus ?? createBus();
|
||||
const zugang = createZugang();
|
||||
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
|
||||
// den ersten ersetzen, nicht danebenlaufen.
|
||||
const vorfuehrung = { abbruch: null as AbortController | null };
|
||||
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
|
||||
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => {
|
||||
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Interner Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(opts.port, host, () => {
|
||||
log.info(`Overlay-Server laeuft auf ${host}:${opts.port}` +
|
||||
(opts.publicUrl ? ` (oeffentlich: ${opts.publicUrl})` : ''));
|
||||
if (!zugang.hatPasswort() && opts.publicUrl) {
|
||||
log.warn('Es ist kein Passwort gesetzt - die Einrichtungsseite steht offen. ' +
|
||||
'Setzen mit: abdulive web:passwort');
|
||||
}
|
||||
});
|
||||
server.on('error', (err) => {
|
||||
log.error(`Overlay-Server nicht gestartet: ${err.message}` +
|
||||
((err as NodeJS.ErrnoException).code === 'EADDRINUSE'
|
||||
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
|
||||
});
|
||||
|
||||
return { bus, zugang, close: () => server.close(), port: opts.port };
|
||||
}
|
||||
|
||||
type Vorfuehrung = { abbruch: AbortController | null };
|
||||
|
||||
async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse,
|
||||
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
|
||||
const p = url.pathname;
|
||||
|
||||
// ---- Anmeldung. Bewusst vor allem anderen.
|
||||
if (p === '/login' && req.method === 'POST') {
|
||||
const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? '');
|
||||
if (!cookie) return redirect(res, '/?fehler=1');
|
||||
res.writeHead(303, { location: '/', 'set-cookie': cookie });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (p === '/logout') {
|
||||
res.writeHead(303, { location: '/', 'set-cookie': zugang.abmelden() });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Der Rueckruf von Twitch. Kein Passwort: Twitch schickt den Browser
|
||||
// hierher zurueck, und der `state`-Wert ist der Schutz.
|
||||
if (p === '/callback' && opts.setup) {
|
||||
if (await setupRoutes(req, res, opts.setup, url)) return;
|
||||
}
|
||||
|
||||
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
|
||||
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
|
||||
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||
|
||||
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
||||
if (page || p === '/events') {
|
||||
if (!zugang.darfOverlay(req, url)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8',
|
||||
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||
'Sie steht fertig auf der Uebersichtsseite.');
|
||||
}
|
||||
if (p === '/events') return sse(req, res, bus);
|
||||
return sendFile(res, page!.file);
|
||||
}
|
||||
|
||||
// ---- Ab hier nur mit Anmeldung.
|
||||
if (!zugang.istAngemeldet(req)) {
|
||||
return send(res, 401, 'text/html; charset=utf-8',
|
||||
loginPage(opts.channel, url.searchParams.has('fehler')));
|
||||
}
|
||||
|
||||
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
|
||||
// davon landet in der Datenbank, auf Discord oder bei Twitch.
|
||||
if (p === '/test' && req.method === 'POST') {
|
||||
const form = await readForm(req);
|
||||
const art = (form.get('art') ?? '') as TestArt | 'demo-an' | 'demo-aus';
|
||||
const laeuftStream = zustand(bus).live;
|
||||
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, demo: vorfuehrung.abbruch !== null }));
|
||||
|
||||
if (art === 'demo-aus') {
|
||||
vorfuehrung.abbruch?.abort();
|
||||
vorfuehrung.abbruch = null;
|
||||
// Der letzte erfundene Stand bleibt sonst im Bild stehen.
|
||||
bus.publish({ kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null });
|
||||
return antwort('Vorführung gestoppt.');
|
||||
}
|
||||
|
||||
if (art === 'demo-an') {
|
||||
if (laeuftStream) {
|
||||
return antwort('Nicht während eines Streams: die Vorführung überschreibt ' +
|
||||
'Titel, Kategorie und Zuschauerzahl in der Statusleiste.', 409);
|
||||
}
|
||||
vorfuehrung.abbruch?.abort();
|
||||
vorfuehrung.abbruch = new AbortController();
|
||||
startDemo(bus, vorfuehrung.abbruch.signal);
|
||||
return antwort('Vorführung läuft.');
|
||||
}
|
||||
|
||||
const knopf = TEST_KNOEPFE.find((k) => k.art === art);
|
||||
if (!knopf) return antwort('Unbekannter Knopf.', 400);
|
||||
if (knopf.nurOffline && laeuftStream) {
|
||||
return antwort('Gesperrt, solange der Stream läuft - das würde in der ' +
|
||||
'Statusleiste stehen bleiben.', 409);
|
||||
}
|
||||
|
||||
// Die neueste Nachricht aus dem Rueckblick, damit "letzte löschen" etwas
|
||||
// trifft, das auch wirklich im Bild steht.
|
||||
const chat = zustand(bus).chat;
|
||||
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
||||
bus.publish(ev);
|
||||
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||
return antwort(`${knopf.label} ausgelöst.`);
|
||||
}
|
||||
|
||||
// ---- Vorlese-Warteschlange
|
||||
if (p === '/tts' && req.method === 'POST' && opts.tts) {
|
||||
const form = await readForm(req);
|
||||
const was = form.get('was') ?? '';
|
||||
const id = form.get('id') ?? '';
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, wartend: opts.tts!.wartend() }));
|
||||
|
||||
if (was === 'still') { opts.tts.still(); return antwort('Alles still.'); }
|
||||
if (was === 'alle-ablehnen') {
|
||||
const n = opts.tts.alleAblehnen();
|
||||
return antwort(`${n} Nachricht(en) abgelehnt.`);
|
||||
}
|
||||
if (was === 'freigeben') {
|
||||
return antwort(opts.tts.freigeben(id) ? 'Wird vorgelesen.' : 'Nicht mehr da.');
|
||||
}
|
||||
if (was === 'ablehnen') {
|
||||
return antwort(opts.tts.ablehnen(id) ? 'Abgelehnt.' : 'Nicht mehr da.');
|
||||
}
|
||||
return antwort('Unbekannt.', 400);
|
||||
}
|
||||
|
||||
if (p === '/tts/wartend' && opts.tts) {
|
||||
return send(res, 200, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ wartend: opts.tts.wartend() }));
|
||||
}
|
||||
|
||||
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerOverlayKey();
|
||||
return redirect(res, '/');
|
||||
}
|
||||
|
||||
if (p === '/' || p === '/index.html') {
|
||||
return send(res, 200, 'text/html; charset=utf-8',
|
||||
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
|
||||
}
|
||||
|
||||
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
|
||||
|
||||
send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden. Die Uebersicht steht auf /');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- SSE
|
||||
|
||||
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
// Ohne das puffert so mancher Proxy den Strom, und die Einblendung kommt
|
||||
// gesammelt nach zwei Minuten. Nginx beachtet genau diesen Kopf.
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
|
||||
const write = (ev: OverlayEvent): void => {
|
||||
// `retry` sagt dem Browser, wie schnell er neu verbinden soll. Vorgabe
|
||||
// waeren drei Sekunden; nach einem Neustart mitten im Stream ist eine
|
||||
// Sekunde besser, und mehr kostet es hier nicht.
|
||||
res.write(`retry: 1000\ndata: ${JSON.stringify(ev)}\n\n`);
|
||||
};
|
||||
|
||||
// Erst anmelden, dann den Schnappschuss: andersherum wuerde ein Ereignis,
|
||||
// das genau dazwischen faellt, verloren gehen.
|
||||
const unsubscribe = bus.subscribe(write);
|
||||
write(bus.snapshot());
|
||||
|
||||
// Als echtes Ereignis, nicht als SSE-Kommentar: ein Kommentar haelt die
|
||||
// Verbindung zwar offen, ist im Browser aber unsichtbar. Die Seite soll
|
||||
// merken koennen, dass laengst nichts mehr kam, und selbst neu verbinden.
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ kind: 'beat', t: Date.now() })}\n\n`),
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const end = (): void => { clearInterval(beat); unsubscribe(); };
|
||||
req.on('close', end);
|
||||
res.on('error', end);
|
||||
}
|
||||
|
||||
/** Der aktuelle Stand aus dem Bus, ohne den Typ an jeder Stelle aufzudroeseln. */
|
||||
function zustand(bus: Bus) {
|
||||
const s = bus.snapshot();
|
||||
if (s.kind !== 'state') throw new Error('Schnappschuss hat die falsche Form');
|
||||
return { live: s.stream.live, chat: s.chat };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeug
|
||||
|
||||
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > 16 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function redirect(res: http.ServerResponse, to: string): void {
|
||||
res.writeHead(303, { location: to });
|
||||
res.end();
|
||||
}
|
||||
|
||||
function send(res: http.ServerResponse, code: number, type: string, body: string | Buffer): void {
|
||||
res.writeHead(code, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
// Der Schluessel steht in der Adresse. Ohne diesen Kopf traegt ihn jeder
|
||||
// Bildaufruf zu Twitchs Emote-CDN im Referer mit hinaus.
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendFile(res: http.ServerResponse, rel: string): void {
|
||||
// Aufloesen und danach pruefen, ob das Ergebnis noch unter WEB_ROOT liegt.
|
||||
// `..` im Pfad ist der Klassiker, und ein Server, der beliebige Dateien
|
||||
// ausliefert, liefert irgendwann secrets.json aus.
|
||||
const file = path.resolve(WEB_ROOT, rel);
|
||||
if (!file.startsWith(WEB_ROOT + path.sep)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8', 'Nicht erlaubt');
|
||||
}
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = fs.readFileSync(file);
|
||||
} catch {
|
||||
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
|
||||
}
|
||||
send(res, 200, TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Seiten
|
||||
|
||||
const SEITEN_CSS = `
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 2.5rem clamp(1rem, 5vw, 4rem); background: #0d0b14; color: #e9e6f3;
|
||||
font: 16px/1.6 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
|
||||
h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h1 span { color: #a970ff; }
|
||||
p.lead { color: #9b94b3; margin: 0 0 2rem; max-width: 62ch; }
|
||||
table { border-collapse: collapse; width: 100%; max-width: 74rem; }
|
||||
td { padding: .9rem 1rem; border-top: 1px solid #241f35; vertical-align: top; }
|
||||
tr:first-child td { border-top: 0; }
|
||||
.hint { color: #8b84a3; font-size: .875rem; }
|
||||
.size { color: #8b84a3; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
code { background: #171327; padding: .25rem .5rem; border-radius: .4rem; word-break: break-all;
|
||||
font-family: "Cascadia Mono", Consolas, monospace; font-size: .8rem; }
|
||||
button, a.knopf { font: inherit; font-size: .85rem; margin-left: .5rem; background: none;
|
||||
border: 1px solid #3a3352; color: #c9c2e0; border-radius: .4rem;
|
||||
padding: .2rem .6rem; cursor: pointer; text-decoration: none; }
|
||||
button:hover, a.knopf:hover { border-color: #a970ff; color: #fff; }
|
||||
input { font: inherit; background: #171327; border: 1px solid #3a3352; color: #e9e6f3;
|
||||
border-radius: .4rem; padding: .5rem .7rem; }
|
||||
.status { margin-top: 2rem; color: #8b84a3; font-size: .9rem; }
|
||||
.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%;
|
||||
background: #ef4444; margin-right: .4rem; }
|
||||
.dot.on { background: #22c55e; }
|
||||
ol { max-width: 62ch; color: #9b94b3; }
|
||||
.warn { color: #ffc53d; }
|
||||
h2 { font-size: 1.1rem; margin: 2.5rem 0 .4rem; }
|
||||
.gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
|
||||
margin-bottom: .6rem; }
|
||||
.gruppenname { display: inline-block; min-width: 9rem; color: #8b84a3;
|
||||
font-size: .85rem; }
|
||||
button.test { margin-left: 0; }
|
||||
button.test:disabled { opacity: .35; cursor: not-allowed; border-color: #241f35; }
|
||||
.warnbox { border: 1px solid #ffc53d; color: #ffc53d; border-radius: .5rem;
|
||||
padding: .6rem .9rem; margin: .8rem 0; max-width: 62ch; }
|
||||
.meldung { min-height: 1.5rem; font-size: .9rem; color: #8b84a3; margin-top: .8rem; }
|
||||
.meldung.ok { color: #4ade80; }
|
||||
.meldung.fehler { color: #ffc53d; }
|
||||
.wartend { display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
|
||||
.wartend:first-child { border-top: 0; }
|
||||
.wartend .hint { max-width: 60ch; word-break: break-word; }
|
||||
button.tts { margin-left: .4rem; }
|
||||
`;
|
||||
|
||||
export function loginPage(channel: string, fehler: boolean): string {
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Anmelden</title><style>${SEITEN_CSS}
|
||||
body { display: grid; place-items: center; height: 100vh; padding: 0; }
|
||||
form { text-align: left; }
|
||||
</style>
|
||||
<form method="post" action="/login">
|
||||
<h1>abdulive <span>${escape(channel)}</span></h1>
|
||||
<p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p>
|
||||
<input type="password" name="passwort" autofocus autocomplete="current-password" size="30">
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Seite, die man aufmacht, bevor man OBS einrichtet. Sie ist bewusst hier
|
||||
* und nicht in public/: sie kennt die oeffentliche Adresse, den Schluessel und
|
||||
* die Seitenliste, und das soll nicht an zwei Stellen stehen.
|
||||
*/
|
||||
export function indexPage(
|
||||
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false
|
||||
): string {
|
||||
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
|
||||
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
|
||||
const ttsFeld = opts.tts ? `
|
||||
<h2>Vorlesen: wartet auf Freigabe</h2>
|
||||
<p class="lead">Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die
|
||||
Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
|
||||
<div id="wartend"><span class="hint">wird geladen …</span></div>
|
||||
<div class="gruppe" style="margin-top:.8rem">
|
||||
<button class="tts" data-was="alle-ablehnen">Alle ablehnen</button>
|
||||
<button class="tts" data-was="still">Sofort still</button>
|
||||
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
|
||||
</div>
|
||||
<p id="tts-meldung" class="meldung"> </p>` : '';
|
||||
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
|
||||
const rows = PAGES.map((p) => {
|
||||
const id = `u${p.path.slice(1)}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${p.title}</strong><div class="hint">${p.hint}</div></td>
|
||||
<td class="size">${p.w} × ${p.h}</td>
|
||||
<td>
|
||||
<code id="${id}">${base}${p.path}?key=${key}</code>
|
||||
<button data-copy="${id}">kopieren</button>
|
||||
<a class="knopf" href="${p.path}?probe=1" target="_blank" rel="noreferrer">ansehen</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const;
|
||||
const testfeld = gruppen.map((g) => `
|
||||
<div class="gruppe">
|
||||
<span class="gruppenname">${g}</span>
|
||||
${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) =>
|
||||
`<button class="test" data-art="${k.art}"${k.nurOffline ? ' data-offline="1"' : ''}>${k.label}</button>`
|
||||
).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style>
|
||||
<h1>Overlay <span>${escape(opts.channel)}</span></h1>
|
||||
<p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
|
||||
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
|
||||
verpasst das Overlay alles, was in einer anderen Szene passiert.</p>
|
||||
<table>${rows}</table>
|
||||
<p class="status"><span class="dot" id="dot"></span><span id="txt">verbinde ...</span>
|
||||
· ${bus.clients()} verbundene Quelle(n)
|
||||
${opts.setup ? ' · <a class="knopf" href="/setup">Einrichtung</a>' +
|
||||
'<a class="knopf" href="/health">Status</a>' : ''}
|
||||
<a class="knopf" href="/logout">Abmelden</a>
|
||||
</p>
|
||||
<h2>Ausprobieren</h2>
|
||||
<p class="lead">Löst ein erfundenes Ereignis aus. Es geht nur an die
|
||||
Browser-Sources - nichts davon landet in der Datenbank, auf Discord oder bei
|
||||
Twitch. Am besten die Seiten in einem zweiten Fenster offen haben
|
||||
(„ansehen“ oben).</p>
|
||||
<div id="live-warnung" class="warnbox" hidden>
|
||||
Der Stream läuft gerade. Was du hier auslöst, sehen die Zuschauer.
|
||||
</div>
|
||||
${testfeld}
|
||||
<div class="gruppe">
|
||||
<span class="gruppenname">Dauerbetrieb</span>
|
||||
<button class="test" id="demo" data-art="${demoLaeuft ? 'demo-aus' : 'demo-an'}">
|
||||
${demoLaeuft ? 'Vorführung stoppen' : 'Vorführung starten'}
|
||||
</button>
|
||||
<span class="hint">Erfundener Chat und alle paar Sekunden eine Einblendung,
|
||||
bis du wieder stoppst.</span>
|
||||
</div>
|
||||
<p id="meldung" class="meldung"> </p>
|
||||
|
||||
${ttsFeld}
|
||||
|
||||
<h2>Schlüssel</h2>
|
||||
<form method="post" action="/schluessel/neu">
|
||||
<button type="submit">Schlüssel neu würfeln</button>
|
||||
<span class="hint">Danach müssen alle Adressen in OBS neu eingetragen werden.</span>
|
||||
</form>
|
||||
<ol>
|
||||
<li>Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.</li>
|
||||
<li>Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.</li>
|
||||
<li>Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.</li>
|
||||
</ol>
|
||||
<script>
|
||||
for (const b of document.querySelectorAll('button[data-copy]')) {
|
||||
b.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
|
||||
b.textContent = 'kopiert';
|
||||
setTimeout(() => { b.textContent = 'kopieren'; }, 1200);
|
||||
});
|
||||
}
|
||||
const es = new EventSource('/events');
|
||||
const dot = document.getElementById('dot'), txt = document.getElementById('txt');
|
||||
es.onopen = () => { dot.classList.add('on'); txt.textContent = 'verbunden'; };
|
||||
es.onerror = () => { dot.classList.remove('on'); txt.textContent = 'getrennt, versuche es erneut'; };
|
||||
const warnung = document.getElementById('live-warnung');
|
||||
const meldung = document.getElementById('meldung');
|
||||
|
||||
function setzeLive(live) {
|
||||
warnung.hidden = !live;
|
||||
// Knöpfe, die einen Zustand setzen, bleiben während eines Streams aus:
|
||||
// sie würden in der Statusleiste stehen bleiben.
|
||||
for (const b of document.querySelectorAll('button.test[data-offline]')) b.disabled = live;
|
||||
const demo = document.getElementById('demo');
|
||||
if (demo.dataset.art === 'demo-an') demo.disabled = live;
|
||||
}
|
||||
|
||||
es.onmessage = (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.kind === 'state') {
|
||||
txt.textContent = ev.stream.live
|
||||
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
|
||||
: 'verbunden - kein Stream';
|
||||
setzeLive(ev.stream.live);
|
||||
} else if (ev.kind === 'stream') {
|
||||
setzeLive(ev.live);
|
||||
}
|
||||
};
|
||||
|
||||
let sagAb;
|
||||
async function loese(art) {
|
||||
const res = await fetch('/test', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ art }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
|
||||
meldung.textContent = body.text;
|
||||
meldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
|
||||
clearTimeout(sagAb);
|
||||
sagAb = setTimeout(() => { meldung.innerHTML = ' '; meldung.className = 'meldung'; }, 4000);
|
||||
|
||||
const demo = document.getElementById('demo');
|
||||
demo.dataset.art = body.demo ? 'demo-aus' : 'demo-an';
|
||||
demo.textContent = body.demo ? 'Vorführung stoppen' : 'Vorführung starten';
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll('button.test')) {
|
||||
b.addEventListener('click', () => loese(b.dataset.art));
|
||||
}
|
||||
|
||||
const wartendEl = document.getElementById('wartend');
|
||||
if (wartendEl) {
|
||||
const ttsMeldung = document.getElementById('tts-meldung');
|
||||
|
||||
function zeichne(liste) {
|
||||
if (!liste.length) {
|
||||
wartendEl.innerHTML = '<span class="hint">Nichts wartet.</span>';
|
||||
return;
|
||||
}
|
||||
wartendEl.innerHTML = liste.map((w) => \`
|
||||
<div class="wartend">
|
||||
<div>
|
||||
<strong>\${w.name}</strong>
|
||||
<div class="hint">\${w.text}</div>
|
||||
</div>
|
||||
<div style="white-space:nowrap">
|
||||
<button class="tts" data-was="freigeben" data-id="\${w.id}">Vorlesen</button>
|
||||
<button class="tts" data-was="ablehnen" data-id="\${w.id}">Ablehnen</button>
|
||||
</div>
|
||||
</div>\`).join('');
|
||||
}
|
||||
|
||||
async function ttsRuf(was, id) {
|
||||
const res = await fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ was, id: id || '' }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status, wartend: [] }));
|
||||
ttsMeldung.textContent = body.text || '';
|
||||
zeichne(body.wartend || []);
|
||||
setTimeout(() => { ttsMeldung.innerHTML = ' '; }, 4000);
|
||||
}
|
||||
|
||||
wartendEl.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button.tts');
|
||||
if (b) ttsRuf(b.dataset.was, b.dataset.id);
|
||||
});
|
||||
for (const b of document.querySelectorAll('.gruppe button.tts')) {
|
||||
b.addEventListener('click', () => ttsRuf(b.dataset.was));
|
||||
}
|
||||
|
||||
// Kurzer Takt: eine Einlösung soll nicht erst nach einem Neuladen
|
||||
// auftauchen. Kostet nichts, die Liste ist winzig.
|
||||
const laden = () => fetch('/tts/wartend')
|
||||
.then((r) => r.json()).then((b) => zeichne(b.wartend || [])).catch(() => {});
|
||||
laden();
|
||||
setInterval(laden, 3000);
|
||||
}
|
||||
</script>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]!));
|
||||
}
|
||||
Reference in new issue
Block a user