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>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-04 00:15:50 +02:00
1 parent 5622499877
commit eb9eab949e
11 files changed
+469 -9

No files matched your search

+8
View File
@@ -108,3 +108,11 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
assert.equal(n, 1);
assert.equal(bus.clients(), 0);
});
test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => {
const bus = createBus();
assert.equal(schnappschuss(bus).liegestuetze, 0);
bus.publish({ kind: 'liegestuetze', offen: 20, dazu: 10, name: 'Kevin' });
bus.publish({ kind: 'liegestuetze', offen: 15 });
assert.equal(schnappschuss(bus).liegestuetze, 15);
});
+21 -2
View File
@@ -65,6 +65,17 @@ export type StreamEvent = {
startedAt: string | null;
};
/**
* Neuer Stand der offenen Liegestuetze. `dazu` und `name` nur bei einer
* Einloesung - eine Korrektur von Hand soll nicht aussehen wie eine.
*/
export type LiegestuetzeEvent = {
kind: 'liegestuetze';
offen: number;
dazu?: number;
name?: string;
};
export type OverlayEvent =
| ChatEvent
| AlertEvent
@@ -88,10 +99,15 @@ export type OverlayEvent =
}
/** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' }
| LiegestuetzeEvent
| { kind: 'scene'; scene: string }
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
| {
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
/** Offene Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
liegestuetze: number;
};
/**
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
@@ -118,6 +134,7 @@ export function createBus(): Bus {
let stream: StreamEvent = {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
};
let liegestuetze = 0;
return {
publish(ev) {
@@ -131,6 +148,8 @@ export function createBus(): Bus {
if (alerts.length > KEEP_ALERTS) alerts.shift();
} else if (ev.kind === 'stream') {
stream = ev;
} else if (ev.kind === 'liegestuetze') {
liegestuetze = ev.offen;
} else if (ev.kind === 'clear') {
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
// naechste Neustart der Browser-Source es zurueck ins Bild.
@@ -153,7 +172,7 @@ export function createBus(): Bus {
return () => { listeners.delete(fn); };
},
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }),
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }),
clients: () => listeners.size,
};
}
+104 -1
View File
@@ -64,6 +64,8 @@ export const PAGES = [
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 = {
@@ -96,6 +98,12 @@ export type OverlayOptions = {
/** 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 {
@@ -167,7 +175,11 @@ async function handle(
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);
// 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)) {
@@ -276,6 +288,27 @@ async function handle(
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, '/');
@@ -351,6 +384,13 @@ function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
});
}
/** 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();
@@ -427,6 +467,11 @@ const SEITEN_CSS = `
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; }
`;
@@ -466,6 +511,25 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
<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)}`;
@@ -524,6 +588,8 @@ ${testfeld}
${ttsFeld}
${lzFeld}
<h2>Schlüssel</h2>
<form method="post" action="/schluessel/neu">
<button type="submit">Schlüssel neu würfeln</button>
@@ -565,11 +631,48 @@ ${ttsFeld}
? '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', {