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:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:13:28 +02:00
1 parent 3b606e36fb
commit bc2c8f6cbe
24 files changed
+3221 -8

No files matched your search

+627
View File
@@ -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">&nbsp;</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} &times; ${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>
&nbsp;·&nbsp; ${bus.clients()} verbundene Quelle(n)
${opts.setup ? '&nbsp;·&nbsp; <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
(&bdquo;ansehen&ldquo; 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">&nbsp;</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 = '&nbsp;'; 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 = '&nbsp;'; }, 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]!));
}