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>
754 lines
32 KiB
TypeScript
754 lines
32 KiB
TypeScript
// 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"> </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"> </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}
|
||
|
||
${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 = ' '; 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 = ' '; 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]!));
|
||
}
|