Files
abdulive/apps/overlay-server/src/server.ts
T
ZeasyAlexandClaude Opus 5.5 eb9eab949e Liegestuetze: Zaehler aus Kanalpunkten, Overlay und Korrektur von Hand
Neue Aktion `liegestuetze` fuer Belohnungen: jede Einloesung zaehlt um
`anzahl` hoch (Vorgabe 5). Der Stand liegt in der Datenbank und
uebersteht einen Neustart. Browser-Source /liegestuetze zeigt ihn, die
Uebersichtsseite hat Knoepfe zum Herunterzaehlen und Setzen.

Nebenbei: POST /tts landete bei der Overlay-Seite statt bei der
Freigabe. Seiten werden jetzt nur noch bei GET ausgeliefert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:15:50 +02:00

754 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: '/kamera', file: 'kamera.html', w: 520, h: 380,
title: 'Kamerarahmen', hint: 'Rahmen um die Facecam mit Zuschauern und Laufzeit. Position 0,0, über der Kamera.' },
{ 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.' },
{ path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200,
title: 'Liegestütze', hint: 'Offene Liegestütze aus Kanalpunkten. Zählt bei Einlösung hoch, korrigieren unten auf dieser Seite.' },
] 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;
/** Spricht einen Text sofort, ohne Warteschlange. Fuer den Testknopf. */
probe: (name: string, text: string) => void;
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
audio: (id: string) => Buffer | undefined;
};
/** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */
liegestuetze?: {
stand: () => number;
aendern: (n: number) => number;
setzen: (n: number) => number;
};
};
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.
// Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
// Uebersicht (POST, mit Anmeldung). Ohne die Einschraenkung bekamen die
// statt einer Antwort die Overlay-Seite - oder ohne Schluessel ein 403.
const page = req.method === 'GET' || req.method === 'HEAD'
? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId) {
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);
if (audioId) {
const wav = opts.tts?.audio(audioId);
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
res.writeHead(200, {
'content-type': 'audio/wav', 'content-length': wav.length, 'cache-control': 'no-store',
});
res.end(wav);
return;
}
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);
// Vorlesen geht durch den Hub, damit der Knopf die echte Stimme prueft
// und nicht nur die Anzeige.
if (ev.kind === 'tts' && opts.tts) {
opts.tts.probe(ev.name, ev.text);
log.info('Testereignis ausgeloest: tts (mit Stimme)');
return antwort(`${knopf.label} ausgelöst.`);
}
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() }));
}
// ---- Liegestuetze von Hand korrigieren
if (p === '/liegestuetze' && req.method === 'POST' && opts.liegestuetze) {
const form = await readForm(req);
const was = form.get('was') ?? '';
const n = zahlAus(form.get('wert'));
const antwort = (text: string, code = 200): void =>
send(res, code, 'application/json; charset=utf-8',
JSON.stringify({ text, offen: opts.liegestuetze!.stand() }));
if (n === null) return antwort('Keine ganze Zahl.', 400);
if (was === 'aendern') {
const offen = opts.liegestuetze.aendern(n);
return antwort(`${n > 0 ? '+' : ''}${n}, jetzt ${offen} offen.`);
}
if (was === 'setzen') {
if (n < 0) return antwort('Weniger als 0 geht nicht.', 400);
return antwort(`Auf ${opts.liegestuetze.setzen(n)} gesetzt.`);
}
return antwort('Unbekannt.', 400);
}
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);
});
}
/** Eingabe von der Seite in eine ganze Zahl. Alles andere ist `null`. */
export function zahlAus(roh: string | null | undefined): number | null {
if (roh === null || roh === undefined) return null;
const s = roh.trim();
return /^[+-]?\d{1,6}$/.test(s) ? Number(s) : null;
}
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; }
.lz-stand { font: 700 3rem/1 "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif;
font-variant-numeric: tabular-nums; margin: .4rem 0 1rem; }
.lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin-left: .5rem; }
button.lz { margin-left: 0; min-width: 3.2rem; }
input.lz { width: 6.5rem; padding: .2rem .6rem; font-size: .85rem; }
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 lzFeld = opts.liegestuetze ? `
<h2>Liegestütze</h2>
<p class="lead">Jede Einlösung zählt hoch. Hier herunterzählen, wenn sie gemacht
sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.</p>
<div class="lz-stand"><span id="lz-offen">${opts.liegestuetze.stand()}</span><small>offen</small></div>
<div class="gruppe">
<button class="lz" data-um="-10">−10</button>
<button class="lz" data-um="-5">−5</button>
<button class="lz" data-um="-1">−1</button>
<button class="lz" data-um="1">+1</button>
<button class="lz" data-um="5">+5</button>
<button class="lz" data-um="10">+10</button>
</div>
<form class="gruppe" id="lz-form">
<input class="lz" id="lz-wert" type="number" min="0" step="1" inputmode="numeric" placeholder="Anzahl">
<button type="submit" class="lz">setzen</button>
<button type="button" class="lz" id="lz-null">auf 0</button>
</form>
<p id="lz-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}
${lzFeld}
<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);
zeigeLiegestuetze(ev.liegestuetze);
} else if (ev.kind === 'stream') {
setzeLive(ev.live);
} else if (ev.kind === 'liegestuetze') {
zeigeLiegestuetze(ev.offen);
}
};
// Der Stand kommt über den Ereignisstrom, damit eine Einlösung auch hier
// sofort sichtbar ist und zwei offene Fenster nicht auseinanderlaufen.
function zeigeLiegestuetze(n) {
const el = document.getElementById('lz-offen');
if (el && typeof n === 'number') el.textContent = n.toLocaleString('de-DE');
}
const lzMeldung = document.getElementById('lz-meldung');
async function lzRuf(was, wert) {
const res = await fetch('/liegestuetze', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ was, wert: String(wert) }),
});
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
zeigeLiegestuetze(body.offen);
lzMeldung.textContent = body.text || '';
lzMeldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
setTimeout(() => { lzMeldung.innerHTML = '&nbsp;'; lzMeldung.className = 'meldung'; }, 4000);
}
for (const b of document.querySelectorAll('button.lz[data-um]')) {
b.addEventListener('click', () => lzRuf('aendern', b.dataset.um));
}
const lzForm = document.getElementById('lz-form');
if (lzForm) {
lzForm.addEventListener('submit', (e) => {
e.preventDefault();
const feld = document.getElementById('lz-wert');
if (feld.value === '') return;
lzRuf('setzen', feld.value);
feld.value = '';
});
document.getElementById('lz-null').addEventListener('click', () => lzRuf('setzen', 0));
}
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]!));
}