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:
1 parent
5622499877
commit
eb9eab949e
11 files changed
+469
-9
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"> </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)}`;
|
||||
@@ -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 = ' '; 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', {
|
||||
|
||||
Reference in new issue
Block a user