Filipe: „die videos muessen perfekt laufen wenn ich die reaktions
mache."
GEMESSEN, NICHT VERMUTET. mess-reaktion hat einen neuen Abschnitt:
Er liest den Spielstand am ECHTEN <video>-Element in beiden Browsern,
rechnet beide auf denselben Augenblick um und sagt, wie weit Host und
Zuschauerin auseinanderliegen. Die vorhandene Pruefung fragte nur, ob
Start und Stopp ANKOMMEN -- zwei Videos koennen beide laufen und
trotzdem zwanzig Sekunden auseinander sein.
vorher nachher
Im Lauf 0,12 s -0,04 s
Wer dazukommt 6,91 s 0,26 s
Uhr des Geraets 30 s vor -29,97 s 0,26 s
DER SCHLIMMSTE FUND: DIE UHR DES TELEFONS
Der Zuschauer rechnete `Date.now() - stand.gesendet`. `gesendet` kam
vom SERVER, `Date.now()` vom Geraet -- zwei Uhren, die nie jemand
verglichen hat. Wessen Telefon dreissig Sekunden vorgeht, schaute
29,97 s weiter als alle anderen. Kein langsames Netz, kein schwaches
Handy. Oertlich faellt das nie auf: Auf einem Rechner sind beide
Uhren dieselbe.
Jetzt geht ein ALTER hinaus statt einer Uhrzeit -- die Differenz
zweier SERVERzeiten. Der Empfaenger braucht dafuer gar keine Uhr,
nur eine Stoppuhr (`performance.now()`), und die haelt Zeitumstellung
und Aufwachen aus dem Ruhezustand aus.
WEITER
* WER DAZUKOMMT, STEIGT RICHTIG EIN. Der gespeicherte Stand war so
alt wie der letzte Takt des Hosts; neue Spalte `sekunde_am` sagt,
wann er galt.
* DAS TEMPO GEHT IN DIE HOCHRECHNUNG EIN. Bei 2x laeuft die Videouhr
doppelt so schnell -- das fehlte ganz.
* JEDER RECHNET ALLE ZWEI SEKUNDEN SELBST NACH statt nur auf Zuruf.
Wer stockte, hing bis zu fuenf Sekunden hinterher.
* DER VORLAUF NACH EINEM SPRUNG WIRD GEREGELT, nicht geschaetzt --
gemessen wird der Rest, nicht die Ladezeit (siehe unten).
* PUFFERN IST AUCH IN DER TRANSPORTLEISTE KEINE PAUSE. Der
Start-Stopp-Knopf rief beim Puffern `playVideo()` -- wer Pause
drueckte, bekam „weiter". Lampe und Knopfbild flackerten bei jedem
Stocken. Die Lektion vom 29.09. war nur an einer Stelle eingeloest.
* DAS TEMPO MELDET UEBER `hostMelden()` statt ein zweites Mal von
Hand -- es hielt Puffern fuer Stillstand und hielt damit beim
Umschalten alle an.
* EIN VIDEO-RUNDRUF STATT DREI ABSCHRIFTEN. Beim Einbau hatte ich
zuerst nur eine nachgezogen; das Tauschen der beiden Videos haette
still die alte Rechnung weiterverschickt.
DREI EIGENE FEHLER UNTERWEGS, alle im Text festgehalten
1. ZWEI FUNKTIONEN HIESSEN `springen`. Meine „spring AUF die Stelle"
wurde von der vorhandenen „spring UM so viele Sekunden"
ueberschrieben -- lautlos, rueckwirkend fuer die ganze Datei. Ein
Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird, waere
bei 201 gelandet. DREI BROWSERLAEUFE HABEN ES NICHT GEFUNDEN:
oertlich blieb der Abstand unter der Sprungschwelle, die Zeile
lief kein einziges Mal. Gefunden hat es erst eine Frage an die
ganze Datei -- die steht jetzt als Pruefung drin und wurde
nachgestellt (rot).
2. DER VORLAUF LERNTE NUR NACH OBEN. Gemessen wurde die Ladezeit
nach einem Sprung; ein Sprung in geladenes Material dauert aber
gar nicht, meldet nichts und lehrt nichts. Ergebnis: -0,95 s
stabil. Jetzt wird das ERGEBNIS geregelt statt der Ursache.
3. DER AUSREISSER VON 39,84 s WAR MEINE EIGENE MESSUNG -- der Block
davor schickt eine erfundene Position. Jetzt wird abgeklungen und
in Messreihenfolge ausgegeben.
GEPRUEFT
pruef-reaktion 421/0 (14 neue, darunter der Riegel gegen die
Rueckkehr der Uhrenrechnung und die Wache gegen doppelte
Funktionsnamen -- beide nachgestellt), mess-reaktion ohne ein
einziges ACHTUNG, pruef-buehne 38/0, pruef-struktur, pruef-
zwischenspeicher 34/0.
Schemaaenderung: ADD COLUMN sekunde_am. Datenbank vorher gesichert
und geprueft (integrity_check ok, 20 Personen).
Co-Authored-By: Claude Opus 5 <[email protected]>
4748 lines
197 KiB
JavaScript
4748 lines
197 KiB
JavaScript
/* =====================================================================
|
||
DIE REACTION (28.09.2026)
|
||
|
||
Filipes Kurznotiz, von links nach rechts:
|
||
|
||
Kachel sichtbar -> geschlossen -> Vorbereitung ->
|
||
Wartebereich/Chat -> Countdown -> LIVE ->
|
||
Reaction + Gäste + Chat + PayPal -> Ende
|
||
|
||
---------------------------------------------------------------------
|
||
DREI DINGE LAUFEN HIER GLEICHZEITIG, UND KEINES DARF DAS ANDERE
|
||
ANHALTEN
|
||
|
||
1. DAS VIDEO. Jeder lädt es selbst von YouTube. Übertragen wird
|
||
nur der Spielstand -- Kennung, läuft/pausiert, Sekunde. Das
|
||
ist nicht nur sparsam, es ist der einzige zulässige Weg: Ein
|
||
YouTube-Video weiterzusenden ist eine öffentliche Wiedergabe.
|
||
|
||
2. DIE KAMERAS. Direkt von Mensch zu Mensch, über denselben Weg
|
||
wie die Anrufe im Haus (assets/js/anruf.js seit 18.09.).
|
||
Jeder, der sendet, baut zu jedem, der zusieht, eine eigene
|
||
Verbindung auf.
|
||
|
||
3. DER CHAT. Über den Ereignisstrom, den es im Haus schon gibt.
|
||
Kein zweiter Strom: Manche Browser lassen nur sechs
|
||
gleichzeitige Verbindungen je Adresse zu.
|
||
|
||
---------------------------------------------------------------------
|
||
WER RUFT WEN AN -- UND WARUM DAS EINE REGEL BRAUCHT
|
||
|
||
Wenn zwei Seiten gleichzeitig ein Angebot schicken, scheitern
|
||
beide. In der Fachsprache heißt das „glare", und man löst es
|
||
entweder mit einem aufwendigen Aushandeln oder mit einer Regel,
|
||
die vorher feststeht. Hier steht sie fest:
|
||
|
||
Wer sendet, ruft jeden an, der nur zusieht.
|
||
Rufen sich zwei Sender an, ruft der mit der KLEINEREN Nummer.
|
||
Wer nur zusieht, ruft nie an.
|
||
|
||
Damit gibt es zu jedem Paar genau einen Anrufer, immer, ohne
|
||
Absprache. Die Nummer ist die Personennummer -- sie ist auf beiden
|
||
Seiten dieselbe und ändert sich nie.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (art, klasse, text) => {
|
||
const n = document.createElement(art);
|
||
if (klasse) n.className = klasse;
|
||
if (text !== undefined) n.textContent = text;
|
||
return n;
|
||
};
|
||
|
||
/* ==== DER BUEHNENMODUS ===========================================
|
||
|
||
`?nur=buehne` nimmt alles Bedienbare weg: Kopfleiste, Chat,
|
||
Regie, Schild, Senderleiste. Uebrig bleibt, was in den Stream
|
||
gehoert -- das Video und die Kameras.
|
||
|
||
DIE SEITE ARBEITET GENAUSO WEITER. Nur das Aussehen aendert
|
||
sich; Verbindungen, Nachfuehrung und Chat laufen im Hintergrund
|
||
unveraendert. Sonst waere es eine zweite Seite, und die saehe
|
||
nach dem naechsten Umbau anders aus als diese. */
|
||
if (new URLSearchParams(location.search).get('nur') === 'buehne') {
|
||
document.body.dataset.nur = 'buehne';
|
||
}
|
||
|
||
const saal = $('saal');
|
||
const schild = $('schild');
|
||
const fehlerFeld = $('fehler');
|
||
|
||
let lage = null; // die letzte Antwort des Servers
|
||
let meineId = 0;
|
||
let binSender = false; // Host oder Gast -> ich schicke Bild
|
||
let meinStrom = null; // meine Kamera
|
||
let pults = false;
|
||
|
||
/* Die Verbindungen, je Gegenüber eine. Sie stehen in einer Map und
|
||
nicht in Einzelvariablen: Mit einer Variablen hat man genau eine
|
||
Verbindung, und das ist der Fall, den es hier nie gibt. */
|
||
const drähte = new Map(); // personId -> { pc, video }
|
||
|
||
/* WER GERADE ANGERUFEN WIRD -- und zwar ab der ERSTEN Zeile von
|
||
`anrufen()`, nicht erst wenn die Verbindung steht.
|
||
|
||
Dazwischen liegt ein `await` (die Kamera muss erst freigegeben
|
||
werden), und in dieser Lücke läuft alles andere weiter: der Takt
|
||
alle 30 Sekunden, jede Chatnachricht, jede Änderung an der Lage.
|
||
Jedes Mal stand wieder „den kenne ich noch nicht", und es ging
|
||
ein zweites Angebot hinaus. Der Empfänger antwortete auf beide,
|
||
und die zweite Antwort traf eine Verbindung, die längst stand:
|
||
„Failed to set remote answer sdp: Called in wrong state: stable".
|
||
|
||
Gefunden hat das der echte Browser, nicht das Lesen. */
|
||
const ruftGerade = new Set();
|
||
|
||
/* ---------------------------------------------------------------
|
||
Reden mit dem Server
|
||
--------------------------------------------------------------- */
|
||
async function hole(pfad, einstellungen) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...einstellungen });
|
||
if (a.status === 401) { location.href = '/workspace/'; throw new Error('weg'); }
|
||
return a;
|
||
}
|
||
|
||
function sagFehler(text) {
|
||
if (!fehlerFeld) return;
|
||
fehlerFeld.textContent = text;
|
||
fehlerFeld.hidden = false;
|
||
clearTimeout(sagFehler.uhr);
|
||
sagFehler.uhr = setTimeout(() => { fehlerFeld.hidden = true; }, 6000);
|
||
}
|
||
|
||
function melde(antwort, ersatz) {
|
||
/* Maschinenworte werden übersetzt -- dieselbe Tafel wie überall
|
||
im Haus. Wo sie nichts kennt, steht ein Satz und nicht ein
|
||
Schlüssel. */
|
||
const wort = antwort && antwort.fehler;
|
||
const satz = (window.sagWas && wort) ? window.sagWas(wort) : null;
|
||
sagFehler(satz || ersatz || 'Das hat gerade nicht geklappt.');
|
||
}
|
||
|
||
/* =================================================================
|
||
DER ZUSTAND AUF DEM BILDSCHIRM
|
||
================================================================= */
|
||
/* ==== DAS REGISTER „WERBUNG" =============================
|
||
|
||
ALLES KOMMT VOM SERVER -- die Arten, die Tempi, die Plaetze,
|
||
die Groessen. Eine zweite Liste hier waere eine, die
|
||
irgendwann von der ersten abweicht; dann steht ein Knopf da,
|
||
den der Server mit 400 ablehnt, und niemand weiss warum. */
|
||
const ECKEN_WORT = {
|
||
aus: 'aus', ol: 'oben links', om: 'oben mittig', or: 'oben rechts',
|
||
ul: 'unten links', um: 'unten mittig', ur: 'unten rechts',
|
||
};
|
||
const ART_WORT = { aus: 'Aus', laufband: 'Laufband', wechsel: 'Wechsel' };
|
||
let neuesBild = 'keins';
|
||
|
||
/* Ein Aufruf an die Werbe-Wege. Fuenf Bedienelemente schicken
|
||
dasselbe Muster; einmal geschrieben kann es nicht an vier
|
||
Stellen abweichen. */
|
||
async function werbungRuf(pfad, koerper, methode) {
|
||
const a = await hole(pfad, {
|
||
method: methode || 'POST',
|
||
...(koerper ? {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(koerper),
|
||
} : {}),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return false; }
|
||
await standHolen();
|
||
return true;
|
||
}
|
||
|
||
const werbungSetzen = (felder) =>
|
||
werbungRuf('/workspace/api/reaktion/werbung', felder);
|
||
|
||
function werbungPultBauen() {
|
||
if (!lage?.ich?.host) return;
|
||
|
||
/* ---- Die Art ---- */
|
||
const areihe = $('bandart-reihe');
|
||
if (areihe) {
|
||
areihe.textContent = '';
|
||
for (const art of (lage.banner_arten || ['aus'])) {
|
||
const k = el('button', 'bandart__knopf', ART_WORT[art] || art);
|
||
k.type = 'button';
|
||
k.dataset.art = art;
|
||
k.addEventListener('click', () => werbungSetzen({ art }));
|
||
areihe.append(k);
|
||
}
|
||
}
|
||
|
||
/* ---- Wie lange ---- */
|
||
const treihe = $('bandtempo-reihe');
|
||
if (treihe) {
|
||
treihe.textContent = '';
|
||
for (const t of (lage.banner_tempi || [16])) {
|
||
const k = el('button', 'bandtempo__knopf', `${t} s`);
|
||
k.type = 'button';
|
||
k.dataset.tempo = String(t);
|
||
k.addEventListener('click', () => werbungSetzen({ tempo: t }));
|
||
treihe.append(k);
|
||
}
|
||
}
|
||
|
||
/* ---- Welches Bild an einer neuen Botschaft ---- */
|
||
const breihe = $('bandbild-reihe');
|
||
if (breihe) {
|
||
breihe.textContent = '';
|
||
const worte = { keins: 'ohne Bild', dogi: 'Husky', hase: 'Häkelhase' };
|
||
for (const b of (lage.banner_bilder || ['keins'])) {
|
||
const k = el('button', 'bandbild__knopf', worte[b] || b);
|
||
k.type = 'button';
|
||
k.dataset.bild = b;
|
||
k.setAttribute('aria-pressed', String(b === neuesBild));
|
||
k.addEventListener('click', () => {
|
||
neuesBild = b;
|
||
for (const g of breihe.children) {
|
||
g.setAttribute('aria-pressed', String(g.dataset.bild === b));
|
||
}
|
||
});
|
||
breihe.append(k);
|
||
}
|
||
}
|
||
|
||
/* ---- Die zwei Ecken ---- */
|
||
for (const [welches, feld, wort] of [
|
||
['dogi', 'ecke_dogi', 'Der Husky'],
|
||
['hase', 'ecke_hase', 'Der Häkelhase'],
|
||
]) {
|
||
const kasten = $(`eckenwahl-${welches}`);
|
||
if (!kasten) continue;
|
||
kasten.textContent = '';
|
||
kasten.append(el('span', 'eckenwahl__wort', wort));
|
||
const gitter = el('div', 'eckenwahl__gitter');
|
||
for (const platz of (lage.ecken_plaetze || ['aus'])) {
|
||
/* JEDER KNOPF IST EIN ABBILD DER LEINWAND mit einem Feld
|
||
dort, wo das Bild landet. Sechs Knoepfe mit „oben links",
|
||
„oben mittig" … waeren sechs Zeilen Text, die man lesen
|
||
muss; ein Bild erkennt man. Das Wort bleibt trotzdem
|
||
dran -- fuer Vorleseprogramme und fuer den, der Farben
|
||
schlecht unterscheidet. */
|
||
const k = el('button', 'eckenwahl__knopf');
|
||
k.type = 'button';
|
||
k.dataset.platz = platz;
|
||
k.title = ECKEN_WORT[platz] || platz;
|
||
k.setAttribute('aria-label', `${wort}: ${ECKEN_WORT[platz] || platz}`);
|
||
if (platz === 'aus') {
|
||
k.append(el('span', 'eckenwahl__aus', 'aus'));
|
||
} else {
|
||
k.append(el('span', 'eckenwahl__punkt'));
|
||
}
|
||
k.addEventListener('click', () => werbungSetzen({ [feld]: platz }));
|
||
gitter.append(k);
|
||
}
|
||
kasten.append(gitter);
|
||
}
|
||
|
||
/* ---- Wie gross ---- */
|
||
const greihe = $('eckengroesse-reihe');
|
||
if (greihe) {
|
||
greihe.textContent = '';
|
||
for (const g of (lage.ecken_groessen || [1])) {
|
||
const k = el('button', 'eckengroesse__knopf',
|
||
g === 1 ? '1×' : `${String(g).replace('.', ',')}×`);
|
||
k.type = 'button';
|
||
k.dataset.gross = String(g);
|
||
k.addEventListener('click', () => werbungSetzen({ ecke_gross: g }));
|
||
greihe.append(k);
|
||
}
|
||
}
|
||
|
||
/* ---- Eine neue Botschaft ---- */
|
||
const anlegen = $('band-anlegen');
|
||
if (anlegen && !anlegen.dataset.bereit) {
|
||
anlegen.dataset.bereit = 'ja';
|
||
anlegen.addEventListener('click', async () => {
|
||
const text = $('band-neu').value.trim();
|
||
if (!text) { melde(null, 'Schreib erst, was dastehen soll.'); return; }
|
||
const ok = await werbungRuf('/workspace/api/reaktion/banner', {
|
||
text, unterzeile: $('band-neu-unter').value.trim(), bild: neuesBild,
|
||
});
|
||
if (!ok) return;
|
||
$('band-neu').value = '';
|
||
$('band-neu-unter').value = '';
|
||
});
|
||
}
|
||
}
|
||
|
||
/* Die Liste der Botschaften -- sie aendert sich haeufiger als die
|
||
Knopfreihen und wird deshalb getrennt gezeichnet. */
|
||
function bandlisteZeichnen() {
|
||
const liste = $('bandliste');
|
||
if (!liste || !lage?.ich?.host) return;
|
||
const alle = lage.banner_alle || [];
|
||
liste.textContent = '';
|
||
if (!alle.length) {
|
||
const leer = el('li', 'bandliste__leer',
|
||
'Noch keine Botschaft. Trag unten eine ein.');
|
||
liste.append(leer);
|
||
return;
|
||
}
|
||
alle.forEach((b, i) => {
|
||
const z = el('li', 'bandzeile');
|
||
z.dataset.aktiv = b.aktiv ? 'ja' : 'nein';
|
||
|
||
const an = el('button', 'bandzeile__an');
|
||
an.type = 'button';
|
||
an.setAttribute('aria-pressed', String(!!b.aktiv));
|
||
an.setAttribute('aria-label', b.aktiv ? 'läuft — abschalten' : 'aus — einschalten');
|
||
an.title = b.aktiv ? 'läuft' : 'aus';
|
||
an.addEventListener('click', async () => {
|
||
await werbungRuf(`/workspace/api/reaktion/banner/${b.id}`, { aktiv: !b.aktiv });
|
||
});
|
||
z.append(an);
|
||
|
||
const texte = el('div', 'bandzeile__texte');
|
||
texte.append(el('div', 'bandzeile__text', b.text));
|
||
if (b.unterzeile) texte.append(el('div', 'bandzeile__unter', b.unterzeile));
|
||
z.append(texte);
|
||
|
||
if (b.bild && b.bild !== 'keins') {
|
||
const bild = el('img', 'bandzeile__bild');
|
||
bild.src = `assets/img/werbung-${b.bild}.webp`;
|
||
bild.alt = b.bild === 'dogi' ? 'Husky' : 'Häkelhase';
|
||
z.append(bild);
|
||
}
|
||
|
||
const tun = el('div', 'bandzeile__tun');
|
||
for (const [wohin, zeichen, wort] of [['hoch', '▲', 'nach oben'], ['runter', '▼', 'nach unten']]) {
|
||
const k = el('button', 'bandzeile__schieb', zeichen);
|
||
k.type = 'button';
|
||
k.setAttribute('aria-label', wort);
|
||
k.title = wort;
|
||
k.disabled = (wohin === 'hoch' && i === 0) || (wohin === 'runter' && i === alle.length - 1);
|
||
k.addEventListener('click', async () => {
|
||
await werbungRuf(`/workspace/api/reaktion/banner/${b.id}/schieben`, { wohin });
|
||
});
|
||
tun.append(k);
|
||
}
|
||
const weg = el('button', 'bandzeile__weg', '×');
|
||
weg.type = 'button';
|
||
weg.setAttribute('aria-label', 'Botschaft löschen');
|
||
weg.title = 'löschen';
|
||
weg.addEventListener('click', async () => {
|
||
/* EINE RUECKFRAGE, WEIL ES NICHT ZURUECKGEHT. Bei „lösen"
|
||
im Chat steht hier keine -- das laesst sich rueckgaengig
|
||
machen. Eine Botschaft ist danach weg samt ihrem Text. */
|
||
/* `was:` UND NICHT `text:` — der Dialog kennt `text` nicht und
|
||
hat den Satz stillschweigend verworfen. `endgueltig` setzt
|
||
den Hinweis „nicht rückgängig" von selbst darunter, in
|
||
denselben Worten wie überall im Haus. */
|
||
const ja = await window.frageNach?.({
|
||
titel: 'Botschaft löschen?',
|
||
was: `„${b.text}" wird aus dem Band genommen.`,
|
||
endgueltig: true,
|
||
ja: 'Löschen', nein: 'Behalten',
|
||
});
|
||
if (ja === false || ja === undefined) return;
|
||
await werbungRuf(`/workspace/api/reaktion/banner/${b.id}`, null, 'DELETE');
|
||
});
|
||
tun.append(weg);
|
||
z.append(tun);
|
||
liste.append(z);
|
||
});
|
||
}
|
||
|
||
/* ==== DIE WERBEEINBLENDUNG ===============================
|
||
Sie haengt an der Leinwand und nicht am Saal: Sie gehoert ueber
|
||
das Bild, und nur dort. Eingehaengt wird sie EINMAL -- ein
|
||
Band, das bei jedem Rundruf neu entstuende, finge mitten im
|
||
Satz wieder von vorn an. */
|
||
let werbung = null;
|
||
function werbungZeichnen() {
|
||
if (!window.Werbung) return;
|
||
const kasten = $('leinwand');
|
||
if (!kasten) return;
|
||
if (!werbung) werbung = window.Werbung.einhaengen(kasten);
|
||
werbung.setzen(lage?.werbung);
|
||
}
|
||
|
||
function zeichnen() {
|
||
if (!lage) return;
|
||
saal.dataset.stand = lage.stand;
|
||
document.body.dataset.stand = lage.stand;
|
||
meineId = lage.ich.id;
|
||
werbungZeichnen();
|
||
|
||
/* WER RAUS IST, SIEHT AB HIER NICHTS MEHR. Die Prüfung steht ganz
|
||
oben und nicht am Ende: Alles darunter baut den Saal auf, und
|
||
ein Saal, der sich noch aufbaut, während darüber steht „du bist
|
||
raus", ist eine widersprüchliche Auskunft. */
|
||
if (lage.ich.massnahme && lage.ich.massnahme.art === 'raus') {
|
||
rausgenommen('aus_der_sendung');
|
||
return;
|
||
}
|
||
|
||
const warSender = binSender;
|
||
binSender = lage.ich.host || lage.ich.gast;
|
||
/* WER GERADE ZUM SENDER GEWORDEN IST, BAUT SEINE LEITUNGEN NEU AUF.
|
||
|
||
Solange ich Zuschauer war, sind meine Verbindungen OHNE Spur
|
||
entstanden -- ich hatte ja nichts zu senden. Eine bestehende
|
||
Verbindung nachträglich zu füllen ginge nur mit einer neuen
|
||
Verhandlung; sie wegzuwerfen ist kürzer und sicher.
|
||
|
||
Ohne das blieb das Bild eines frisch dazugeholten Gastes beim
|
||
Host für immer schwarz, ohne eine einzige Fehlermeldung. */
|
||
if (binSender && !warSender && drähte.size) {
|
||
console.info('[reaktion] Ich sende jetzt -- Leitungen neu aufbauen.');
|
||
leitungenNeu();
|
||
}
|
||
|
||
/* ---- Das Schild ---- */
|
||
const worte = { zu: 'Geschlossen', vorbereitung: 'Gleich', live: 'Auf Sendung' };
|
||
$('schild-wort').textContent = worte[lage.stand] || '…';
|
||
const zahl = $('schild-zahl');
|
||
if (lage.stand === 'zu') {
|
||
zahl.hidden = true;
|
||
} else {
|
||
zahl.hidden = false;
|
||
zahl.textContent = lage.dabei === 1 ? '1 dabei' : `${lage.dabei} dabei`;
|
||
}
|
||
|
||
/* ---- Zu ---- */
|
||
const naechste = $('zu-naechste');
|
||
if (lage.beginnt_am && lage.stand === 'zu') {
|
||
const d = new Date(lage.beginnt_am);
|
||
naechste.hidden = Number.isNaN(d.getTime());
|
||
if (!naechste.hidden) {
|
||
naechste.textContent = 'Als Nächstes: ' + (lage.titel || 'Reaction') + ' · '
|
||
+ d.toLocaleString('de-DE', { weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
} else {
|
||
naechste.hidden = true;
|
||
}
|
||
|
||
/* ---- Vorbereitung ---- */
|
||
$('warte-titel').textContent = lage.titel || 'Reaction startet bald';
|
||
const bild = $('warte-bild');
|
||
/* Kein eigenes Vorschaubild? Dann das von YouTube. Das ist keine
|
||
Notlösung, sondern meistens das bessere Bild -- es zeigt genau
|
||
das Video, um das es geht. */
|
||
const quelle = lage.vorschau
|
||
|| (lage.video ? `https://i.ytimg.com/vi/${lage.video}/maxresdefault.jpg` : '');
|
||
bild.style.backgroundImage = quelle ? `url("${quelle}")` : '';
|
||
|
||
/* WAS ALS NÄCHSTES KOMMT, steht auf der Wartetafel -- für alle.
|
||
Nicht die ganze Liste: Der Abend soll nicht vorher im Netz
|
||
stehen. Aber etwas, das Lust macht, gehört dorthin. */
|
||
const gleich = $('warte-naechstes');
|
||
if (gleich) {
|
||
gleich.hidden = !(lage.naechstes && lage.stand !== 'zu');
|
||
if (!gleich.hidden) gleich.textContent = 'Danach: ' + lage.naechstes;
|
||
}
|
||
|
||
/* ---- Live ---- */
|
||
const kino = $('teil-live');
|
||
kino.dataset.layout = lage.layout || 'kino';
|
||
/* Größe und Ecke der Kamerafenster. Sie stehen am Kino und nicht
|
||
an jedem Fenster einzeln -- ein Wert, eine Stelle, und die
|
||
Fenster erben ihn. */
|
||
kino.dataset.ecke = lage.kamera_ecke || 'ur';
|
||
kino.style.setProperty('--kam-gross', String(lage.kamera_gross || 1));
|
||
/* Das Tempo steht am Kino und nicht nur im Pult: Der Zuschauer
|
||
soll sehen, warum es schneller läuft -- sonst sucht er den
|
||
Fehler bei seiner Leitung. */
|
||
const tempo = lage.tempo || 1;
|
||
kino.dataset.tempo = String(tempo);
|
||
const schild = $('leinwand-tempo');
|
||
if (schild) {
|
||
schild.hidden = tempo === 1;
|
||
schild.textContent = `${String(tempo).replace('.', ',')}× Geschwindigkeit`;
|
||
}
|
||
kamerasZeichnen();
|
||
senderleisteZeichnen();
|
||
chatStufeZeichnen();
|
||
|
||
/* ---- Das Pult ---- */
|
||
/* DIE LEISTE ZUERST. Sie steht über dem Saal und muss sichtbar
|
||
sein, BEVOR `pultAufbauen()` die Marke misst -- ein verborgenes
|
||
Element hat kein Rechteck, und die Marke säße auf null. */
|
||
/* DER TRANSPORT GEHOERT DENEN, DIE SENDEN -- wie die
|
||
Registerleiste. Er steht seit dem 28.09. unter dem Saal und
|
||
nicht mehr im Register „Sendung": Der Play-Knopf ist der
|
||
eine, den man mitten in einer Sendung blind treffen muss, und
|
||
in einem Register ist er weg, sobald man auf „Ton" geht. */
|
||
const transport = $('transport');
|
||
if (transport) transport.hidden = !lage.ich.host;
|
||
|
||
const leiste = $('regieleiste');
|
||
if (leiste) leiste.hidden = !lage.ich.host;
|
||
const pult = $('pult');
|
||
pult.hidden = !lage.ich.host;
|
||
if (lage.ich.host && !pults) { pultAufbauen(); pults = true; }
|
||
if (lage.ich.host) pultFuellen();
|
||
|
||
countdownTakt();
|
||
}
|
||
|
||
/* ---- Die Kamerafenster ------------------------------------------
|
||
Für jeden Sender ein Fenster. Es entsteht EINMAL und bleibt dann
|
||
stehen -- ein Fenster, das bei jedem Zeichnen neu gebaut wird,
|
||
verliert seinen Videostrom, und das Bild fängt von vorn an zu
|
||
ruckeln. */
|
||
function kamerasZeichnen() {
|
||
const behaelter = $('kameras');
|
||
const sollen = [];
|
||
if (lage.host) sollen.push({ ...lage.host, host: true, stumm: false, platz: 0 });
|
||
for (const g of lage.gaeste) sollen.push({ ...g, host: false });
|
||
|
||
const da = new Set();
|
||
for (const s of sollen) {
|
||
da.add(String(s.id));
|
||
let fenster = behaelter.querySelector(`.kamera[data-wer="${s.id}"]`);
|
||
if (!fenster) {
|
||
fenster = el('div', 'kamera');
|
||
fenster.dataset.wer = String(s.id);
|
||
const v = el('video');
|
||
v.autoplay = true; v.playsInline = true;
|
||
/* ALLES STARTET STUMM -- auch die fremden Kameras.
|
||
|
||
Ein Videoelement mit Ton darf nicht von allein losgehen;
|
||
das verbietet jeder Browser, und zwar ohne Meldung. Das
|
||
Ergebnis wäre ein schwarzes Rechteck, obwohl die
|
||
Verbindung steht. Gemessen genau so: `videoWidth` 640 und
|
||
trotzdem kein Bild.
|
||
|
||
Stumm darf losgehen. Den Ton schaltet `tonAn()` frei --
|
||
entweder über den Knopf oder bei der ersten Berührung der
|
||
Seite. Das EIGENE Bild bleibt für immer stumm: Sich selbst
|
||
mit Verzögerung zu hören ist der schnellste Weg, dass
|
||
jemand aufhört zu reden. */
|
||
v.muted = true;
|
||
if (s.id !== meineId) v.dataset.fremd = 'ja';
|
||
fenster.append(v);
|
||
fenster.append(el('span', 'kamera__name', s.name));
|
||
behaelter.append(fenster);
|
||
}
|
||
fenster.dataset.host = s.host ? 'ja' : 'nein';
|
||
/* EIN ABGESCHALTETES BILD IST NICHT DASSELBE WIE EIN KAPUTTES.
|
||
Ohne diese Beschriftung sind beide ein schwarzes Rechteck,
|
||
und man fragt den anderen, ob bei ihm etwas klemmt. */
|
||
fenster.dataset.kamera = s.kamera_aus ? 'aus' : 'an';
|
||
/* EIN BILDSCHIRM WIRD NICHT ZUGESCHNITTEN. Ein Kamerafenster
|
||
steht auf `object-fit: cover` -- richtig fuer ein Gesicht,
|
||
falsch fuer einen Schreibtisch: Dort faellt genau das weg,
|
||
worum es geht. Das Stilblatt schaltet daran auf `contain`. */
|
||
fenster.dataset.bildschirm = s.bildschirm ? 'ja' : 'nein';
|
||
/* `stumm` setzt der Host fuer alle, `mikro_aus` der Mensch fuer
|
||
sich. Beides sieht man dem Fenster an, und beides heisst
|
||
dasselbe: von hier kommt gerade kein Ton. */
|
||
fenster.dataset.stumm = (s.stumm || s.mikro_aus) ? 'ja' : 'nein';
|
||
let schild = fenster.querySelector('.kamera__aus');
|
||
if (s.kamera_aus && !schild) {
|
||
schild = el('span', 'kamera__aus', 'Kamera aus');
|
||
fenster.append(schild);
|
||
} else if (!s.kamera_aus && schild) {
|
||
schild.remove();
|
||
}
|
||
const draht = drähte.get(s.id);
|
||
const v = fenster.querySelector('video');
|
||
/* IM EIGENEN FENSTER STEHT, WAS DIE ANDEREN SEHEN. Zeigte es
|
||
weiter die Kamera, waehrend der Bildschirm hinausgeht, waere
|
||
es die eine Anzeige, der man nicht trauen kann -- und man
|
||
merkt es erst, wenn jemand nachfragt. */
|
||
const meins = bildschirmStrom || meinStrom;
|
||
if (s.id === meineId && meins && v.srcObject !== meins) {
|
||
v.srcObject = meins;
|
||
v.play().catch(() => { /* stumm darf immer */ });
|
||
}
|
||
if (draht && draht.strom && v.srcObject !== draht.strom) {
|
||
v.srcObject = draht.strom;
|
||
if (tonFrei) v.muted = false;
|
||
v.play().catch(() => { /* der Knopf hilft nach */ });
|
||
}
|
||
}
|
||
for (const alt of [...behaelter.querySelectorAll('.kamera')]) {
|
||
if (!da.has(alt.dataset.wer)) alt.remove();
|
||
}
|
||
}
|
||
|
||
/* ---- Die Senderleiste --------------------------------------------
|
||
Kamera und Mikro gehoeren dem, der sendet -- Host wie Gast. Sie
|
||
liegen ueber dem Bild, weil man waehrend einer Sendung dorthin
|
||
sieht und nicht auf ein Register.
|
||
|
||
DER ZUSTAND STEHT AM SERVER, NICHT NUR IM BROWSER. Die Spur
|
||
abzuschalten reicht technisch (`track.enabled = false`), aber
|
||
dann sehen die anderen ein schwarzes Fenster ohne Grund. Der
|
||
Server sagt es weiter, damit „Kamera aus" darin steht. */
|
||
function senderleisteZeichnen() {
|
||
const leiste = $('senderleiste');
|
||
if (!leiste) return;
|
||
const sende = !!(lage && binSender && lage.stand === 'live');
|
||
leiste.hidden = !sende;
|
||
if (!sende) return;
|
||
|
||
const kAus = !!lage.ich.kamera_aus;
|
||
const mAus = !!lage.ich.mikro_aus;
|
||
$('s-kamera').setAttribute('aria-pressed', String(kAus));
|
||
$('s-mikro').setAttribute('aria-pressed', String(mAus));
|
||
|
||
/* ==== DER BILDSCHIRM ==========================================
|
||
`aria-pressed` steht hier fuer „teilt gerade" und nicht wie
|
||
bei den Nachbarn fuer „ist aus". Das ist kein Bruch, sondern
|
||
der Unterschied: Kamera und Mikro schaltet man AB, den
|
||
Bildschirm schaltet man AN.
|
||
|
||
UND DIE KAMERA WIRD DERWEIL GRAU. Solange der Bildschirm
|
||
hinausgeht, ist die Bildspur der Leitungen der Bildschirm --
|
||
der Kameraknopf haette dann keine Wirkung mehr auf das, was
|
||
ankommt. Ein Knopf, der still nichts tut, ist schlimmer als
|
||
einer, der nicht da ist. */
|
||
const teilt = !!lage.ich.bildschirm;
|
||
const knopf = $('s-teilen');
|
||
if (knopf) {
|
||
knopf.setAttribute('aria-pressed', String(teilt));
|
||
knopf.title = teilt
|
||
? 'Teilen beenden — dann läuft wieder deine Kamera'
|
||
: 'Deinen Bildschirm statt der Kamera zeigen';
|
||
$('s-teilen-wort').textContent = teilt ? 'Teilt' : 'Bildschirm';
|
||
}
|
||
$('s-kamera').disabled = teilt;
|
||
$('s-kamera').title = teilt
|
||
? 'Solange du den Bildschirm teilst, geht dein Bildschirm hinaus'
|
||
: '';
|
||
$('s-kamera-wort').textContent = kAus ? 'Kamera an' : 'Kamera';
|
||
$('s-mikro-wort').textContent = mAus ? 'Mikro an' : 'Mikro';
|
||
$('s-kamera').title = kAus ? 'Bild wieder einschalten' : 'Bild abschalten';
|
||
$('s-mikro').title = mAus ? 'Mikro wieder einschalten' : 'Mikro abschalten';
|
||
}
|
||
|
||
/** Kamera oder Mikro schalten -- Geraet UND Ansage in einem Zug.
|
||
*
|
||
* ERST DAS GERAET, DANN DIE ANSAGE. Andersherum stuende bei allen
|
||
* „Kamera aus", waehrend noch ein Bild fliesst -- und das ist die
|
||
* unangenehmere Reihenfolge: Man glaubt, man sei unsichtbar. */
|
||
/* =================================================================
|
||
DEN BILDSCHIRM TEILEN
|
||
|
||
Filipe: „waere es nicht einfach eine bildschirm uebertragung zu
|
||
installieren und es zu perfektionieren?"
|
||
|
||
Es ist einfach, weil der Weg schon da ist: Die Kamerabilder
|
||
laufen als Direktverbindung von Mensch zu Mensch. Geteilt wird
|
||
deshalb nicht NEBEN der Kamera, sondern AN IHRER STELLE --
|
||
`replaceTrack` tauscht die Bildspur in jeder bestehenden
|
||
Leitung aus, ohne dass eine einzige neu ausgehandelt werden
|
||
muss. Eine zweite Spur daneben waere eine zweite Verhandlung je
|
||
Zuschauer, und jede davon kann scheitern.
|
||
|
||
WAS DAMIT NICHT GEHT, STEHT IM REGIEPULT und nicht nur hier:
|
||
Netflix, Disney+ und Prime bleiben schwarz (Kopierschutz), und
|
||
einen Film weiterzusenden waere eine oeffentliche Wiedergabe.
|
||
================================================================= */
|
||
let bildschirmStrom = null;
|
||
let bildschirmSpur = null;
|
||
|
||
async function bildschirmSchalten() {
|
||
if (bildschirmStrom) { await bildschirmBeenden(); return; }
|
||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||
sagFehler('Dieser Browser kann den Bildschirm nicht teilen. '
|
||
+ 'Am Handy geht es grundsätzlich nicht — dort gibt es die Funktion nicht.');
|
||
return;
|
||
}
|
||
/* OHNE KAMERA KEINE LEITUNG. Die Verbindungen entstehen aus dem
|
||
eigenen Strom; ohne ihn gibt es nichts, worin man tauschen
|
||
könnte. */
|
||
if (!meinStrom) await kameraHolen();
|
||
if (!meinStrom) return;
|
||
|
||
let strom;
|
||
try {
|
||
strom = await navigator.mediaDevices.getDisplayMedia({
|
||
/* 15 Bilder je Sekunde und höchstens 1280 breit. Ein
|
||
Bildschirm ist meist ruhig, und jede Verbindung kostet
|
||
den, der teilt, Upload: Bei 12 Zuschauern ist der
|
||
Unterschied zwischen „läuft" und „stockt" genau diese
|
||
Zahl. Gemessen steht die Grenze im Kopf von
|
||
reaktion-tabellen.js. */
|
||
video: { frameRate: { ideal: 15, max: 30 }, width: { max: 1280 } },
|
||
/* KEIN RECHNERTON. `getDisplayMedia` kann ihn liefern, und
|
||
er käme zusätzlich zum Mikrofon heraus -- alles doppelt
|
||
und einmal davon verzögert. Der Ton bleibt beim Mikro. */
|
||
audio: false,
|
||
});
|
||
} catch {
|
||
/* Abgebrochen ist kein Fehler: Wer im Auswahlfenster
|
||
„Abbrechen" drückt, hat sich entschieden. Eine Fehlermeldung
|
||
darauf wäre eine Belehrung. */
|
||
return;
|
||
}
|
||
|
||
bildschirmStrom = strom;
|
||
bildschirmSpur = strom.getVideoTracks()[0] || null;
|
||
/* DER BROWSER HAT EINEN EIGENEN STOPP-KNOPF, unten am Rand. Wer
|
||
ihn drückt, erwartet, dass alles zurückgeht -- ohne diesen
|
||
Zuhörer bliebe die Seite auf „teilt" stehen und sendete ein
|
||
totes Bild. */
|
||
if (bildschirmSpur) {
|
||
bildschirmSpur.addEventListener('ended', () => { bildschirmBeenden(); });
|
||
}
|
||
await bildSpurTauschen(bildschirmSpur);
|
||
await bildschirmMelden(true);
|
||
}
|
||
|
||
async function bildschirmBeenden() {
|
||
if (!bildschirmStrom) return;
|
||
try { for (const sp of bildschirmStrom.getTracks()) sp.stop(); } catch { /* war schon aus */ }
|
||
bildschirmStrom = null;
|
||
bildschirmSpur = null;
|
||
await bildSpurTauschen(meinStrom ? meinStrom.getVideoTracks()[0] : null);
|
||
await bildschirmMelden(false);
|
||
}
|
||
|
||
/** Die Bildspur in JEDER bestehenden Leitung austauschen. */
|
||
async function bildSpurTauschen(spur) {
|
||
for (const d of drähte.values()) {
|
||
const sender = d.pc.getSenders().find((x) => x.track && x.track.kind === 'video');
|
||
if (!sender) continue;
|
||
try { await sender.replaceTrack(spur); } catch { /* diese Leitung eben nicht */ }
|
||
}
|
||
kamerasZeichnen();
|
||
}
|
||
|
||
async function bildschirmMelden(an) {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/senden', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bildschirm: an }),
|
||
});
|
||
if (a.ok) { lage = { ...(await a.json()) }; zeichnen(); }
|
||
} catch { /* die Leitung laeuft trotzdem -- nur das Schild fehlt */ }
|
||
}
|
||
|
||
/* =================================================================
|
||
ALLES ZURUECKSETZEN
|
||
|
||
Filipe: „brauch ich auch noch einen button wo ich alles easy
|
||
zuruecksetzen kann."
|
||
|
||
ZWEI SPALTEN IM DIALOG, und das ist der ganze Punkt: Was geleert
|
||
wird, steht neben dem, was bleibt. Ein „Bist du sicher?" ohne
|
||
diese Liste ist keine Frage, sondern eine Huerde -- man klickt
|
||
sie weg und erfaehrt erst danach, was fehlt.
|
||
================================================================= */
|
||
async function allesZuruecksetzen() {
|
||
if (!window.frageNach) return;
|
||
const ja = await window.frageNach({
|
||
titel: 'Das Pult zurücksetzen?',
|
||
was: 'Titel, Video, zweites Video, Vorschaubild, Beginn, die '
|
||
+ 'Warteschlange und die Gästeliste werden geleert. Anordnung, '
|
||
+ 'Kameragröße, Ecke, Tempo und der Chatmodus gehen auf ihre '
|
||
+ 'Vorgaben zurück.',
|
||
bleibt: 'Spenden, die Dogen der Leute, der Chatverlauf und alle '
|
||
+ 'Maßnahmen der Moderation bleiben unberührt.',
|
||
ja: 'Ja, zurücksetzen',
|
||
nein: 'Doch nicht',
|
||
endgueltig: true,
|
||
});
|
||
if (!ja) return;
|
||
const a = await hole('/workspace/api/reaktion/zuruecksetzen', { method: 'POST' });
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
/* DIE FELDER LEERT DER SERVER NICHT -- er kennt sie nicht. Ohne
|
||
diese Zeilen stuende der alte Text weiter im Eingabefeld,
|
||
waehrend die Sendung leer ist: die eine Anzeige, der man nicht
|
||
trauen kann. */
|
||
for (const id of ['p-titel', 'p-video', 'p-vorschau', 'p-zweit', 'p-beginn']) {
|
||
const e = $(id);
|
||
if (e) e.value = '';
|
||
}
|
||
sagFehler('');
|
||
}
|
||
|
||
async function sendenSchalten(was, aus) {
|
||
if (!meinStrom) return;
|
||
const spuren = was === 'kamera'
|
||
? meinStrom.getVideoTracks() : meinStrom.getAudioTracks();
|
||
for (const sp of spuren) sp.enabled = !aus;
|
||
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/senden', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(was === 'kamera' ? { kamera_aus: aus } : { mikro_aus: aus }),
|
||
});
|
||
if (!a.ok) {
|
||
/* Die Ansage ging nicht durch -- also auch das Geraet zurueck.
|
||
Ein Knopf, der halb wirkt, ist schlimmer als einer, der gar
|
||
nicht wirkt: Man weiss nicht, woran man ist. */
|
||
for (const sp of spuren) sp.enabled = aus;
|
||
melde(await a.json().catch(() => null), 'Das ging gerade nicht.');
|
||
return;
|
||
}
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
} catch {
|
||
for (const sp of spuren) sp.enabled = aus;
|
||
sagFehler('Das ging gerade nicht.');
|
||
}
|
||
}
|
||
|
||
/* ---- Der Ton ----------------------------------------------------
|
||
Er wird EINMAL freigegeben und bleibt dann frei. */
|
||
let tonFrei = false;
|
||
function tonAn() {
|
||
if (tonFrei) return;
|
||
tonFrei = true;
|
||
for (const v of document.querySelectorAll('#kameras video[data-fremd="ja"]')) {
|
||
v.muted = false;
|
||
v.play().catch(() => { /* dann eben beim nächsten Tippen */ });
|
||
}
|
||
/* DER PLAYER GEHÖRT DAZU. Ein Knopf, der die Kameras laut macht
|
||
und das Video stumm lässt, ist schlimmer als keiner: Man drückt
|
||
ihn, etwas passiert, und trotzdem hört man den Film nicht. */
|
||
if (spielerBereit) {
|
||
try {
|
||
spieler.unMute();
|
||
spieler.setVolume(staende.video);
|
||
spieler.playVideo();
|
||
} catch { /* der nächste Takt richtet es */ }
|
||
}
|
||
/* DER KLANGRAUM AUCH. Ein AudioContext startet angehalten und
|
||
darf erst nach einer Handlung des Menschen laufen -- dieselbe
|
||
Regel wie beim Ton. Ohne das stehen die Pegel bei null, und
|
||
der Host denkt, sein Mikro sei tot. */
|
||
try { klangRaumHolen()?.resume(); } catch { /* dann eben nicht */ }
|
||
const knopf = $('ton-an');
|
||
if (knopf) knopf.hidden = true;
|
||
}
|
||
|
||
/* Die erste Berührung irgendwo auf der Seite reicht dem Browser als
|
||
Handlung. Deshalb hängt der Ton auch daran -- die meisten Leute
|
||
tippen ohnehin sofort irgendwo hin, und dann ist der Knopf schon
|
||
weg, bevor er stört. */
|
||
for (const art of ['pointerdown', 'keydown']) {
|
||
window.addEventListener(art, tonAn, { once: true, capture: true });
|
||
}
|
||
|
||
/* =================================================================
|
||
DER COUNTDOWN
|
||
|
||
Er zählt in Sekunden und wird jede Sekunde neu geschrieben --
|
||
nicht jede Minute. Wer bei 4:59 dazukommt, soll nicht bis 3:59
|
||
auf die erste Änderung warten.
|
||
================================================================= */
|
||
let countdownUhr = null;
|
||
function countdownTakt() {
|
||
clearInterval(countdownUhr);
|
||
const kasten = $('countdown');
|
||
if (!lage || lage.stand !== 'vorbereitung' || !lage.beginnt_am) {
|
||
kasten.hidden = true;
|
||
return;
|
||
}
|
||
const ziel = Date.parse(lage.beginnt_am);
|
||
if (!Number.isFinite(ziel)) { kasten.hidden = true; return; }
|
||
kasten.hidden = false;
|
||
|
||
const schreiben = () => {
|
||
const rest = Math.round((ziel - Date.now()) / 1000);
|
||
const zahl = $('countdown-zahl');
|
||
const wort = $('countdown-wort');
|
||
if (rest <= 0) {
|
||
zahl.textContent = 'gleich';
|
||
wort.textContent = 'es geht los';
|
||
clearInterval(countdownUhr);
|
||
return;
|
||
}
|
||
const std = Math.floor(rest / 3600);
|
||
const min = Math.floor((rest % 3600) / 60);
|
||
const sek = rest % 60;
|
||
zahl.textContent = std > 0
|
||
? `${std}:${String(min).padStart(2, '0')}:${String(sek).padStart(2, '0')}`
|
||
: `${min}:${String(sek).padStart(2, '0')}`;
|
||
wort.textContent = 'bis zum Start';
|
||
};
|
||
schreiben();
|
||
countdownUhr = setInterval(schreiben, 1000);
|
||
}
|
||
|
||
/* =================================================================
|
||
DAS VIDEO
|
||
|
||
Die Einbett-API von YouTube wird EINMAL geladen und danach
|
||
fremdgesteuert. Der Host schickt seinen Spielstand; alle anderen
|
||
folgen ihm.
|
||
|
||
NACHGEFUEHRT WIRD NUR BEI ECHTER ABWEICHUNG. Ein Player, dem man
|
||
jede Sekunde eine neue Position gibt, ruckelt sichtbar. Erst ab
|
||
gut einer Sekunde Unterschied wird gesprungen -- darunter
|
||
merkt es niemand, darüber merkt es jeder.
|
||
|
||
---------------------------------------------------------------
|
||
WIE DIE SOLL-STELLE GERECHNET WIRD (neu gefasst 30.09.2026)
|
||
|
||
soll = sekunde + (alter + laufzeit + seither) * tempo
|
||
|
||
Hier stand bis heute `Date.now() - stand.gesendet` — die Uhr des
|
||
Telefons gegen die Uhr des Servers. Zwei Uhren, die nie jemand
|
||
verglichen hat. Gemessen: Ein Gerät, dessen Uhr 30 Sekunden
|
||
vorgeht, spielte das Video **29,97 Sekunden weiter** als alle
|
||
anderen. Kein langsames Netz, kein schwaches Handy — nur eine
|
||
falsch gestellte Uhr, wie sie auf jedem dritten Telefon steht.
|
||
|
||
Die neue Rechnung benutzt überhaupt keine Tagesuhr mehr:
|
||
|
||
`alter` sagt der Server, gerechnet aus ZWEI Serverzeiten.
|
||
Ein Alter ist eine Differenz — dafür braucht der
|
||
Empfänger keinen eigenen Kalender.
|
||
`laufzeit` die halbe kleinste selbst gemessene Umlaufzeit.
|
||
`seither` `performance.now()`, eine Stoppuhr ab Seitenstart.
|
||
Sie überlebt Zeitumstellung, Zeitzonenwechsel und
|
||
das Aufwachen aus dem Ruhezustand; `Date.now()`
|
||
springt dabei.
|
||
`tempo` Bei 2× läuft die Uhr im Video doppelt so schnell.
|
||
Das fehlte bisher ganz — bei einer Sekunde
|
||
Laufzeit war das eine Sekunde Fehler.
|
||
|
||
UND ES WIRD NICHT NUR AUF ZURUF NACHGEFÜHRT. Der Host meldet
|
||
alle fünf Sekunden; wer dazwischen stockt, hing bis zu fünf
|
||
Sekunden hinterher und wurde dann mit einem Ruck geholt. Jetzt
|
||
rechnet jeder alle zwei Sekunden selbst nach — der Anker weiß,
|
||
wo die Sendung steht, auch ohne neue Meldung.
|
||
|
||
WER NICHTS WEISS, SPRINGT NICHT. Kommt kein `alter` mit oder ist
|
||
die Seite so lange weggewesen, dass die Hochrechnung nicht mehr
|
||
trägt, liefert `sollJetzt()` `null` — und `null` heißt „nicht
|
||
anfassen", nicht „auf null springen". Lieber eine Sekunde hinten
|
||
als an der falschen Stelle.
|
||
================================================================= */
|
||
let spieler = null;
|
||
let spielerBereit = false;
|
||
let letztesVideo = '';
|
||
|
||
/* ---- Wie lange eine Meldung zu mir braucht ---------------------
|
||
Die HALBE KLEINSTE Umlaufzeit, nicht der Durchschnitt. Eine
|
||
einzelne langsame Antwort sagt etwas über die Warteschlange
|
||
unterwegs, nicht über den Weg; die schnellste gemessene kommt
|
||
der reinen Laufzeit am nächsten. Dieselbe Überlegung wie bei
|
||
jeder Zeitmessung über ein Netz. */
|
||
let laufzeitMs = 0;
|
||
let kleinsteUmlauf = Infinity;
|
||
function umlaufMerken(ms) {
|
||
if (!(ms >= 0) || ms > 10_000) return;
|
||
if (ms < kleinsteUmlauf) { kleinsteUmlauf = ms; laufzeitMs = ms / 2; }
|
||
}
|
||
|
||
/* ---- Der Anker: der letzte bekannte Stand ----------------------
|
||
`seit` ist eine STOPPUHR (`performance.now()`), keine Uhrzeit.
|
||
Gesetzt wird er in dem Augenblick, in dem die Nachricht ANKOMMT
|
||
— nicht dort, wo sie später verarbeitet wird. Das ist kein
|
||
Detail: Zwischen dem Laden des Stands und dem Fertigwerden des
|
||
YouTube-Spielers vergehen ein bis drei Sekunden, und genau die
|
||
fehlten sonst in der Rechnung. */
|
||
let anker = null;
|
||
function ankerSetzen(stand) {
|
||
if (!stand) return;
|
||
anker = {
|
||
sekunde: Number(stand.sekunde) || 0,
|
||
laeuft: !!stand.laeuft,
|
||
tempo: Number(stand.tempo) || 1,
|
||
/* `null` heißt „unbekannt" und ist etwas anderes als 0. */
|
||
alterMs: Number.isFinite(stand.alter) ? stand.alter : null,
|
||
seit: performance.now(),
|
||
};
|
||
}
|
||
|
||
/* Weiter als das trägt keine Hochrechnung. Wer eine Viertelstunde
|
||
im Hintergrund lag, weiß nichts mehr — und eine Zahl, die aus
|
||
einem Anker von vor einer Viertelstunde stammt, ist schlimmer
|
||
als keine. */
|
||
const ANKER_HAELT = 90;
|
||
|
||
/** Wo müsste ich jetzt stehen? `null` = weiß ich nicht. */
|
||
function sollJetzt() {
|
||
if (!anker) return null;
|
||
if (!anker.laeuft) return anker.sekunde;
|
||
if (anker.alterMs === null) return anker.sekunde;
|
||
const her = (anker.alterMs + laufzeitMs + (performance.now() - anker.seit)) / 1000;
|
||
if (her < 0 || her > ANKER_HAELT) return null;
|
||
return anker.sekunde + her * anker.tempo;
|
||
}
|
||
|
||
/** Wer die Sendung führt, folgt niemandem — er IST der Stand.
|
||
*
|
||
* GEPRÜFT WIRD DIE KENNUNG, NICHT DIE ROLLE: `ich.host` ist bei
|
||
* DogFather UND bei seiner rechten Hand wahr, geführt wird aber
|
||
* von genau einem. Wer danebensteht, muss mitlaufen. */
|
||
function binSteuerHost() {
|
||
return !!(lage?.host?.id && meineId && lage.host.id === meineId);
|
||
}
|
||
|
||
/* ---- Springen, und wie weit vorgehalten wird -------------------
|
||
|
||
Nach einem Sprung lädt der Spieler nach. Bis er wieder spielt,
|
||
ist die Sendung weitergelaufen — man landet also genau um diese
|
||
Ladezeit hinten und braucht bald den nächsten Sprung. Deshalb
|
||
wird ein Vorlauf dazugerechnet.
|
||
|
||
GEMESSEN WIRD DAS ERGEBNIS, NICHT DIE URSACHE.
|
||
|
||
Der erste Anlauf (30.09.2026) stoppte die LADEZEIT: Wie lange
|
||
dauert es vom Sprung bis zum Weiterspielen? Klingt richtig und
|
||
war systematisch falsch. Ein Sprung in einen schon geladenen
|
||
Bereich dauert nämlich gar nicht — der Spieler verlässt den
|
||
Zustand „läuft" nie, die Meldung darüber kommt nie, und gelernt
|
||
wurde ausschließlich aus den LANGSAMEN Sprüngen. Der Vorlauf
|
||
kannte nur den Weg nach oben.
|
||
|
||
Gemessen kam dabei heraus: Die Zuschauerin lag 0,95 s VOR dem
|
||
Host — über acht Messungen auf zwei Hundertstel stabil. Ein
|
||
Mittelwert, der nur eine Sorte Messung sieht, ist keiner.
|
||
|
||
Jetzt wird zweieinhalb Sekunden nach jedem Sprung nachgesehen,
|
||
wie weit man DANEBEN liegt, und der Vorlauf um einen Teil dieses
|
||
Restes verschoben. Zu weit vorn zieht ihn herunter, zu weit
|
||
hinten hinauf — ein Regelkreis auf die Größe, auf die es
|
||
ankommt, statt auf einen Hilfswert, von dem man hofft, dass er
|
||
dasselbe bedeutet.
|
||
|
||
Auf einer schnellen Leitung geht er gegen null, auf einer
|
||
langsamen wächst er mit. Eine feste Zahl wäre wieder die
|
||
Rechnung von gestern. */
|
||
let vorlauf = 0;
|
||
let restPruefenAb = 0;
|
||
/* `aufStelleSpringen` UND NICHT `springen` (berichtigt 30.09.2026).
|
||
|
||
Sie hiess zuerst `springen` -- genau wie die Funktion des Hosts
|
||
1600 Zeilen weiter unten, die UM so viele Sekunden spult. In
|
||
einem gemeinsamen Rumpf gewinnt die spaetere Deklaration, und
|
||
zwar rueckwirkend fuer die ganze Datei. Nachgestellt:
|
||
|
||
function springen(ziel) { return 'MEINER'; }
|
||
function springen(um) { return 'HOSTS'; }
|
||
springen(101); // -> „HOSTS"
|
||
|
||
Ein Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird,
|
||
waere damit bei 201 gelandet. Und beim naechsten Mal bei 302.
|
||
|
||
Die Messung hat es NICHT gefunden: Oertlich blieb der Abstand
|
||
unter der Sprungschwelle, die Zeile lief in drei Laeufen kein
|
||
einziges Mal. Auf einer echten Leitung waere sie staendig
|
||
gelaufen. */
|
||
function aufStelleSpringen(ziel) {
|
||
spieler.seekTo(Math.max(0, ziel + (anker?.laeuft ? vorlauf : 0)), true);
|
||
/* Erst nachsehen, wenn das Nachladen vorbei sein kann. Sofort
|
||
gemessen wäre der „Rest" nur die Ladezeit selbst. */
|
||
restPruefenAb = performance.now() + 2500;
|
||
}
|
||
/** Was ist von dem Sprung übrig geblieben? `weg` ist positiv, wenn
|
||
* man hinten liegt. */
|
||
function vorlaufLernen(weg) {
|
||
if (!restPruefenAb || performance.now() < restPruefenAb) return;
|
||
restPruefenAb = 0;
|
||
/* Mehr als fünf Sekunden Rest ist kein Nachladefehler, sondern
|
||
etwas anderes (ein Sprung des Hosts, ein Videowechsel) — und
|
||
darf den Vorlauf nicht verziehen. */
|
||
if (Math.abs(weg) > 5) return;
|
||
/* Nur ein Teil des Restes, damit einzelne Ausreißer den Wert
|
||
nicht hin und her werfen. */
|
||
vorlauf = Math.max(0, Math.min(1.5, vorlauf + 0.4 * weg));
|
||
}
|
||
|
||
/* Ab wann gesprungen wird. Darunter merkt es niemand; ein Sprung
|
||
kostet dagegen immer ein sichtbares Nachladen. */
|
||
const SPRUNG_AB = 1.2;
|
||
|
||
/** Einmal nachführen: stehe ich da, wo die Sendung steht?
|
||
*
|
||
* `auchHost` — WER FÜHRT, WIRD NICHT NACHGEFÜHRT, mit genau einer
|
||
* Ausnahme: dem ersten Mal. Sein Spieler IST der Stand; ihn alle
|
||
* zwei Sekunden auf seine eigene Meldung zu setzen, wäre ein Echo,
|
||
* das sich selbst hinterherläuft (genau der Fehler vom 29.09., als
|
||
* ein Pufferer den Host über den Umweg des Servers anhielt).
|
||
*
|
||
* Beim Laden hat er aber noch gar keine Stelle. Im Quelltext stand
|
||
* dazu schon: „Lädt der Host mitten in der Sendung neu, soll er
|
||
* dort weitermachen, wo die Sendung steht, und nicht bei null."
|
||
* Ohne diese Ausnahme finge er bei null an — und alle anderen
|
||
* wären ihm eine Sekunde später dorthin gefolgt. */
|
||
function nachfuehren(auchHost = false) {
|
||
if (!spielerBereit || !anker) return;
|
||
if (!auchHost && binSteuerHost()) return;
|
||
const zustand = spieler.getPlayerState();
|
||
/* WÄHREND GEPUFFERT WIRD, WIRD NICHT GESPRUNGEN. Ein Sprung löst
|
||
neues Puffern aus — auf einer schwachen Leitung käme man aus
|
||
dem Nachladen nicht mehr heraus, und genau das fühlt sich an
|
||
wie „das Video hängt". */
|
||
if (zustand === PUFFERT) return;
|
||
const soll = sollJetzt();
|
||
if (soll === null) { standAuffrischen(); return; }
|
||
const ist = spieler.getCurrentTime();
|
||
const weg = soll - ist;
|
||
/* Ablesbar von außen, wie `data-spieler` daneben — die Messung
|
||
braucht eine Zahl, die das Programm selbst für wahr hält. */
|
||
const platz = $('video-platz');
|
||
if (platz) {
|
||
platz.dataset.stelle = ist.toFixed(2);
|
||
platz.dataset.versatz = weg.toFixed(2);
|
||
}
|
||
/* ZUERST LERNEN, DANN ENTSCHEIDEN. Andersherum wäre der Rest
|
||
schon wegkorrigiert, bevor jemand ihn gesehen hat — und der
|
||
Vorlauf bliebe für immer auf seinem ersten Wert. */
|
||
vorlaufLernen(weg);
|
||
if (Math.abs(weg) > SPRUNG_AB) aufStelleSpringen(soll);
|
||
if (anker.laeuft && zustand !== SPIELT && zustand !== PUFFERT) spieler.playVideo();
|
||
if (!anker.laeuft && zustand === SPIELT) spieler.pauseVideo();
|
||
}
|
||
|
||
/* Der eigene Takt. Der Host meldet alle fünf Sekunden; wer
|
||
dazwischen stockt, hing bisher bis zu fünf Sekunden hinterher.
|
||
Zwei Sekunden sind der Kompromiss: oft genug, dass man es nicht
|
||
merkt, selten genug, dass es nichts kostet. */
|
||
let gleichUhr = null;
|
||
function gleichlaufTakt() {
|
||
clearInterval(gleichUhr);
|
||
/* Über eine Pfeilfunktion und nicht direkt: `setInterval`
|
||
übergibt in manchen Umgebungen die Zahl der Verspätungen als
|
||
erstes Argument — das wäre hier ein wahrheitswertiges
|
||
`auchHost` und machte aus der Ausnahme die Regel. */
|
||
gleichUhr = setInterval(() => nachfuehren(), 2000);
|
||
}
|
||
|
||
/* Wenn der Anker nicht mehr trägt (lange im Hintergrund), wird er
|
||
geholt statt geraten. Mit Bremse — sonst fragte eine Seite ohne
|
||
Verbindung im Zwei-Sekunden-Takt. */
|
||
let letztesAuffrischen = 0;
|
||
function standAuffrischen() {
|
||
if (performance.now() - letztesAuffrischen < 10_000) return;
|
||
letztesAuffrischen = performance.now();
|
||
standHolen(true);
|
||
}
|
||
|
||
function apiLaden() {
|
||
if (window.YT && window.YT.Player) { spielerBauen(); return; }
|
||
if (document.getElementById('yt-api')) return;
|
||
const s = document.createElement('script');
|
||
s.id = 'yt-api';
|
||
s.src = 'https://www.youtube.com/iframe_api';
|
||
window.onYouTubeIframeAPIReady = () => spielerBauen();
|
||
document.head.append(s);
|
||
}
|
||
|
||
function spielerBauen() {
|
||
if (spieler || !lage || !lage.video) return;
|
||
const platz = $('video-platz');
|
||
const halter = el('div');
|
||
halter.id = 'yt-spieler';
|
||
platz.append(halter);
|
||
letztesVideo = lage.video;
|
||
spieler = new window.YT.Player('yt-spieler', {
|
||
videoId: lage.video,
|
||
host: 'https://www.youtube-nocookie.com',
|
||
playerVars: {
|
||
rel: 0, modestbranding: 1, playsinline: 1,
|
||
/* STUMM STARTEN -- sonst startet es gar nicht.
|
||
Ein Player mit Ton darf ohne Handlung des Menschen nicht
|
||
losgehen. Gemessen: `onStateChange` ist nie ausgelöst
|
||
worden, bei niemandem. Für einen Zuschauer wäre das eine
|
||
Sackgasse gewesen -- er hat keine Bedienung und hätte nie
|
||
eine Möglichkeit gehabt, es zu starten. */
|
||
mute: 1,
|
||
/* Der Host steuert. Alle anderen sehen keine Bedienung --
|
||
wer mitten in einer gemeinsamen Sendung zurückspult,
|
||
spult nur für sich zurück und wundert sich dann. */
|
||
controls: lage.ich.host ? 1 : 0,
|
||
disablekb: lage.ich.host ? 0 : 1,
|
||
},
|
||
events: {
|
||
onReady: () => {
|
||
spielerBereit = true;
|
||
$('video-warte').hidden = true;
|
||
/* JEDER RICHTET SICH ZUERST NACH DEM STAND -- auch der Host.
|
||
|
||
Vorher stand hier `if (host) takt(); else folgen()`. Der
|
||
Host hat damit nur GEMELDET, wo er steht, und nie selbst
|
||
begonnen. Sein Player blieb unangetastet, `getPlayerState()`
|
||
gab „nicht gestartet", er meldete „laeuft nicht" -- und
|
||
alle anderen folgten ihm brav ins Stehen. Gemessen: bei
|
||
niemandem lief etwas, nicht eine Sekunde.
|
||
|
||
Und es ist auch fuer sich richtig: Laedt der Host mitten
|
||
in der Sendung neu, soll er dort weitermachen, wo die
|
||
Sendung steht, und nicht bei null. */
|
||
folgen(lage);
|
||
if (lage.ich.host) hostTakt();
|
||
/* FÜR ALLE, AUCH FÜR DEN HOST. `nachfuehren()` steigt bei
|
||
dem, der führt, selbst wieder aus — und wechselt die
|
||
Führung mitten in der Sendung, läuft der Takt schon. */
|
||
gleichlaufTakt();
|
||
},
|
||
onStateChange: (e) => {
|
||
/* Der Zustand steht am Element -- ablesbar von aussen, ohne
|
||
in den fremden Rahmen greifen zu muessen. Genau das
|
||
braucht die Messung, um zu sagen: Es LAEUFT wirklich. */
|
||
const worte = { '-1': 'unklar', 0: 'ende', 1: 'laeuft', 2: 'pause',
|
||
3: 'puffert', 5: 'bereit' };
|
||
$('video-platz').dataset.spieler = worte[String(e.data)] || String(e.data);
|
||
if (lage && lage.ich.host) hostMelden();
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Der Host sagt alle fünf Sekunden, wo er steht.
|
||
*
|
||
* Fünf Sekunden und nicht jede: Der Spielstand ändert sich
|
||
* vorhersagbar weiter, jeder rechnet selbst mit -- gemeldet werden
|
||
* muss nur, wenn jemand springt oder pausiert. Der Takt ist das
|
||
* Netz darunter, falls eine Meldung verlorengeht. */
|
||
let hostUhr = null;
|
||
function hostTakt() {
|
||
clearInterval(hostUhr);
|
||
hostUhr = setInterval(hostMelden, 5000);
|
||
}
|
||
/* Die Zustände des YouTube-Spielers, bei ihrem Namen.
|
||
Zahlen im Programmtext sind die Sorte Wissen, die genau einmal
|
||
nachgeschlagen und danach falsch erinnert wird. */
|
||
const SPIELT = 1, PUFFERT = 3;
|
||
|
||
/** @param {object} dazu Was ausser dem Spielstand mitgehen soll
|
||
* (heute nur `tempo`).
|
||
*
|
||
* ES GIBT NUR EINE STELLE, DIE DEN SPIELSTAND MELDET.
|
||
* Der Tempo-Knopf hatte dieselbe Meldung ein zweites Mal von Hand
|
||
* zusammengebaut -- und dabei beides falsch gemacht, was diese
|
||
* Funktion richtig macht: Er hielt Puffern fuer Stillstand
|
||
* (`=== 1`) und liess das Alter der Ablesung weg. Wer das Tempo
|
||
* umstellte, waehrend seine Leitung nachlud, hielt damit alle
|
||
* Zuschauer an. */
|
||
async function hostMelden(dazu = null) {
|
||
if (!lage) return null;
|
||
/* ---- HERZSCHLAG ODER ANSAGE? ------------------------------
|
||
Der Fuenf-Sekunden-Takt meldet nur waehrend der Sendung --
|
||
sonst schriebe eine offene Regieseite dauernd in eine
|
||
Sendung, die gar nicht laeuft.
|
||
|
||
Eine ABSICHTLICHE Ansage (`dazu`, heute das Tempo) geht
|
||
dagegen immer hinaus. Das Tempo laesst sich naemlich vor der
|
||
Sendung stellen; die Route verlangt `nurHost` und sonst
|
||
nichts. Ohne diese Unterscheidung bekaeme Filipe in der
|
||
Vorbereitung „Das Tempo ging nicht." zu sehen.
|
||
|
||
Der Unterschied ist inhaltlich: Ein Herzschlag sagt „so steht
|
||
es gerade" und darf ausfallen. Eine Ansage sagt „mach das ab
|
||
jetzt" und muss ankommen. */
|
||
if (!dazu && (!spielerBereit || lage.stand !== 'live')) return null;
|
||
try {
|
||
/* PUFFERN IST KEIN ANHALTEN (29.09.2026).
|
||
|
||
Hier stand `=== 1`. Zustand 3 heißt „ich will spielen, mir
|
||
fehlen gerade Daten" — der Host hat nichts angehalten, seine
|
||
Leitung stockt eine halbe Sekunde. Gemeldet wurde trotzdem
|
||
„läuft nicht", und zwar sofort, weil `onStateChange` bei
|
||
jedem Zustandswechsel meldet.
|
||
|
||
Zwei Folgen, und die zweite ist die schlimmere:
|
||
· Alle Zuschauer bekamen bei JEDEM Pufferer des Hosts ein
|
||
Pause-Play-Paar. Das ist das Ruckeln, das man für eine
|
||
schlechte eigene Leitung hält.
|
||
· Der Host bekam seine eigene Meldung zurück (er steht in
|
||
`empfaenger()`) und hielt sich damit selbst an.
|
||
|
||
Gemessen in mess-reaktion: `Host=laeuft` → ein gemeldeter
|
||
Pufferer → `Host=pause`. */
|
||
/* OHNE FERTIGEN SPIELER GIBT ES KEINEN SPIELSTAND -- aber die
|
||
Ansage geht trotzdem hinaus. Genau so hat es der Tempo-Knopf
|
||
vorher gemacht (`sekunde: 0`), und das war richtig. */
|
||
const zustand = spielerBereit ? spieler.getPlayerState() : -1;
|
||
const laeuft = zustand === SPIELT || zustand === PUFFERT;
|
||
const gelesenAm = performance.now();
|
||
const sekunde = spielerBereit ? spieler.getCurrentTime() : 0;
|
||
/* WIE ALT DIE ABLESUNG IST, WENN SIE ANKOMMT.
|
||
Das Ablesen, das Zusammenbauen und der Weg zum Server
|
||
dauern zusammen ein paar Dutzend Millisekunden. Ohne diese
|
||
Zahl hielte der Server die Sekunde für taufrisch, und jeder
|
||
Zuschauer säße genau um diese Spanne zu weit hinten.
|
||
Gerechnet wird mit der Stoppuhr, nicht mit dem Kalender —
|
||
eine örtliche Differenz stimmt auch auf einem Gerät mit
|
||
falsch gestellter Uhr. */
|
||
const t0 = performance.now();
|
||
const a = await hole('/workspace/api/reaktion/video', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...(dazu || {}),
|
||
laeuft, sekunde,
|
||
alter: Math.round(performance.now() - gelesenAm + laufzeitMs),
|
||
}),
|
||
});
|
||
umlaufMerken(performance.now() - t0);
|
||
return a;
|
||
} catch { /* der nächste Takt versucht es wieder */ }
|
||
return null;
|
||
}
|
||
|
||
/** Alle anderen folgen.
|
||
*
|
||
* DER ANKER WIRD HIER NICHT GESETZT, sondern dort, wo die
|
||
* Nachricht ANKOMMT (`standHolen` und der Ereignisstrom). Der
|
||
* Unterschied sind ein bis drei Sekunden: `folgen(lage)` läuft
|
||
* aus `onReady`, und bis der YouTube-Spieler fertig ist, vergeht
|
||
* genau diese Zeit. Sie hier in den Anker zu schreiben hieße,
|
||
* sie zu verlieren. */
|
||
function folgen(stand) {
|
||
if (!spielerBereit || !stand) return;
|
||
if (stand.video && stand.video !== letztesVideo) {
|
||
letztesVideo = stand.video;
|
||
/* AN DIE HOCHGERECHNETE STELLE, nicht an die gemeldete. Ein
|
||
Videowechsel mitten in der Sendung ist der Fall, in dem der
|
||
Rückstand am meisten auffällt — alle anderen laufen weiter,
|
||
während hier noch geladen wird. */
|
||
const start = sollJetzt();
|
||
spieler.loadVideoById(stand.video,
|
||
Number.isFinite(start) ? Math.max(0, start) : (stand.sekunde || 0));
|
||
/* YouTube setzt die Geschwindigkeit beim Laden auf 1 zurück.
|
||
Ein kurzer Nachschlag, sobald das neue Video bereit ist --
|
||
sonst liefe jedes zweite Video im falschen Tempo. */
|
||
if (stand.tempo) setTimeout(() => tempoNachziehen(stand.tempo), 900);
|
||
return;
|
||
}
|
||
if (stand.tempo) tempoNachziehen(stand.tempo);
|
||
/* `true`: Aus `folgen()` heraus darf auch der Host einmal
|
||
einsteigen — von hier kommt entweder sein eigener erster
|
||
Aufruf beim Laden oder ein Rundruf, und Rundrufe erreichen
|
||
`folgen()` bei ihm ohnehin nicht. */
|
||
nachfuehren(true);
|
||
}
|
||
|
||
/** Die Geschwindigkeit nachziehen.
|
||
*
|
||
* EIGENE FUNKTION UND NICHT EINE ZEILE IN `folgen()`, weil sie
|
||
* auch bei einem frisch geladenen Video gebraucht wird: `folgen()`
|
||
* steigt beim Videowechsel vorher aus (`return` nach
|
||
* `loadVideoById`), und genau dann ist die Geschwindigkeit wieder
|
||
* auf 1 — YouTube setzt sie beim Laden zurück. Ohne diesen Aufruf
|
||
* fiele das Tempo bei jedem Videowechsel still auf normal.
|
||
*
|
||
* UND NICHT AUF EINEN WERT SETZEN, DEN DER SPIELER NICHT KANN.
|
||
* `getAvailablePlaybackRates()` sagt, was dieses Video hergibt;
|
||
* ein unmöglicher Wert wird schweigend ignoriert, und dann steht
|
||
* der Knopf auf 1,5 und es läuft 1,0. */
|
||
function tempoNachziehen(wert) {
|
||
if (!spielerBereit || !wert) return;
|
||
try {
|
||
const kann = spieler.getAvailablePlaybackRates?.() || [];
|
||
if (kann.length && !kann.includes(wert)) return;
|
||
if (Math.abs((spieler.getPlaybackRate() || 1) - wert) > 0.01) {
|
||
spieler.setPlaybackRate(wert);
|
||
}
|
||
} catch { /* ein Spieler, der noch lädt, kann das noch nicht */ }
|
||
}
|
||
|
||
/* =================================================================
|
||
DIE KAMERAS
|
||
================================================================= */
|
||
let eisAdressen = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||
|
||
async function adressenHolen() {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/adressen');
|
||
if (!a.ok) return;
|
||
const d = await a.json();
|
||
if (Array.isArray(d.adressen) && d.adressen.length) eisAdressen = d.adressen;
|
||
} catch { /* die Vorgabe reicht im eigenen Netz */ }
|
||
}
|
||
|
||
function istSender(id) {
|
||
if (!lage) return false;
|
||
if (lage.host && lage.host.id === id) return true;
|
||
return lage.gaeste.some((g) => g.id === id);
|
||
}
|
||
|
||
/** Die Regel gegen das gleichzeitige Anrufen -- siehe Kopf der Datei. */
|
||
function ichRufeAn(anderer) {
|
||
if (!binSender) return false;
|
||
if (!istSender(anderer)) return true;
|
||
return meineId < anderer;
|
||
}
|
||
|
||
/* WER ALS ERSTER FRAGT, FRAGT FUER ALLE.
|
||
|
||
Die Wache `if (meinStrom)` wirkt erst, wenn die Kamera DA ist.
|
||
Solange die erste Anfrage laeuft, ist `meinStrom` noch null --
|
||
und jeder weitere Anruf stellte eine zweite Anfrage an dasselbe
|
||
Geraet. Gemessen: fuenf Anfragen beim Host. Der Browser gibt ein
|
||
Geraet nicht beliebig oft gleichzeitig heraus; eine davon blieb
|
||
liegen, und mit ihr der Anruf, der darauf wartete. Der Platz in
|
||
`ruftGerade` blieb belegt, und zwischen Host und Gast kam nie
|
||
wieder eine Leitung zustande.
|
||
|
||
Jetzt bekommen alle DASSELBE Versprechen zurueck. */
|
||
let kameraLaeuft = null;
|
||
async function kameraHolen() {
|
||
if (meinStrom) return meinStrom;
|
||
if (kameraLaeuft) return kameraLaeuft;
|
||
kameraLaeuft = kameraWirklichHolen();
|
||
try { return await kameraLaeuft; } finally { kameraLaeuft = null; }
|
||
}
|
||
|
||
async function kameraWirklichHolen() {
|
||
try {
|
||
meinStrom = await navigator.mediaDevices.getUserMedia({
|
||
/* 360p reicht für ein Fenster von 200 Pixeln Breite -- und es
|
||
ist der Unterschied zwischen zwölf Zuschauern und dreien.
|
||
Die Zahl steht hier und nicht in einer Einstellung, weil
|
||
sie zur Fenstergröße gehört, nicht zum Geschmack. */
|
||
video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 24 } },
|
||
audio: { echoCancellation: true, noiseSuppression: true },
|
||
});
|
||
kamerasZeichnen();
|
||
return meinStrom;
|
||
} catch (f) {
|
||
sagFehler('Kamera oder Mikrofon sind nicht freigegeben. '
|
||
+ 'Im Browser oben in der Adresszeile lässt sich das erlauben.');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function draht(anderer) {
|
||
let d = drähte.get(anderer);
|
||
if (d) return d;
|
||
const pc = new RTCPeerConnection({ iceServers: eisAdressen });
|
||
/* WANN DIESER DRAHT ENTSTANDEN IST. Ohne den Zeitpunkt lässt
|
||
sich „steht seit zehn Sekunden und liefert nichts" nicht von
|
||
„ist gerade erst losgegangen" unterscheiden. */
|
||
d = { pc, strom: null, seit: Date.now() };
|
||
drähte.set(anderer, d);
|
||
|
||
pc.onicecandidate = (e) => {
|
||
if (e.candidate) signalSenden(anderer, { typ: 'eis', eis: e.candidate });
|
||
};
|
||
pc.ontrack = (e) => {
|
||
d.strom = e.streams[0];
|
||
kamerasZeichnen();
|
||
};
|
||
pc.onconnectionstatechange = () => {
|
||
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
||
try { pc.close(); } catch { /* egal */ }
|
||
drähte.delete(anderer);
|
||
kamerasZeichnen();
|
||
}
|
||
};
|
||
if (binSender && meinStrom) {
|
||
/* ==== WAS HINAUSGEHT, IST NICHT IMMER DIE KAMERA ==========
|
||
Teile ich gerade den Bildschirm, muss auch eine NEUE
|
||
Verbindung den Bildschirm bekommen -- sonst sieht jeder, der
|
||
waehrenddessen dazukommt, mein Gesicht statt dessen, worum
|
||
es geht. Genau das ist die Sorte Fehler, die erst auffaellt,
|
||
wenn jemand im Chat fragt, was er sehen soll.
|
||
Der Ton kommt weiter vom Mikrofon: `getDisplayMedia` liefert
|
||
den Ton des Rechners, und den will hier niemand. */
|
||
const bild = bildschirmSpur || meinStrom.getVideoTracks()[0];
|
||
const ton = meinStrom.getAudioTracks()[0];
|
||
if (bild) pc.addTrack(bild, meinStrom);
|
||
if (ton) pc.addTrack(ton, meinStrom);
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/** Schickt eine Verbindungsangabe -- und sagt, ob sie ANGEKOMMEN
|
||
* ist.
|
||
*
|
||
* Die Zahl kommt vom Server: Er weiss, wie viele offene Ströme die
|
||
* Gegenseite gerade hat. Null heißt, dass gerade niemand zuhört --
|
||
* und das ist der Unterschied zwischen „unterwegs" und „verloren".
|
||
*/
|
||
async function signalSenden(an, daten) {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/signal', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ an, daten }),
|
||
});
|
||
if (!a.ok) return false;
|
||
const j = await a.json().catch(() => null);
|
||
return (j && j.zugestellt > 0);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Wird an dieser Leitung gerade gearbeitet?
|
||
*
|
||
* ZWEI DINGE MUESSEN GELTEN, und das zweite ist das wichtigere:
|
||
* Der Browser steht nicht auf `stable` (Angebot und Antwort sind
|
||
* noch nicht beide da) UND es ist noch nicht lange her.
|
||
*
|
||
* Ohne die Uhr wird aus „wird verhandelt" ein Dauerzustand: Eine
|
||
* Leitung, deren Antwort nie ankommt, waere vor dem Wachhund UND
|
||
* vor der Bitte um einen Anruf sicher -- fuer immer. Genau das
|
||
* ist passiert, und es hat den Fehler nur von einer Seite auf die
|
||
* andere geschoben. */
|
||
const VERHANDLUNG_MS = 5000;
|
||
function verhandeltNoch(d) {
|
||
return d.pc.signalingState !== 'stable'
|
||
&& Date.now() - d.seit < VERHANDLUNG_MS;
|
||
}
|
||
|
||
/* Ein Anruf, der nie zurueckkommt, sperrt den Platz fuer immer --
|
||
`draehteNachziehen()` ueberspringt jeden, der in `ruftGerade`
|
||
steht. Zwanzig Sekunden sind fuer Angebot und Absenden reichlich;
|
||
was laenger braucht, ist haengengeblieben. */
|
||
const RUF_FRIST = 20000;
|
||
async function anrufen(anderer) {
|
||
if (!ichRufeAn(anderer)) return;
|
||
/* Der Platz wird belegt, BEVOR irgendetwas wartet. */
|
||
if (drähte.has(anderer) || ruftGerade.has(anderer)) return;
|
||
ruftGerade.add(anderer);
|
||
const notbremse = setTimeout(() => {
|
||
if (ruftGerade.delete(anderer)) {
|
||
console.warn('[reaktion] Anruf an', anderer, 'blieb haengen -- Platz freigegeben.');
|
||
}
|
||
}, RUF_FRIST);
|
||
try {
|
||
if (!meinStrom) await kameraHolen();
|
||
const d = draht(anderer);
|
||
const angebot = await d.pc.createOffer();
|
||
await d.pc.setLocalDescription(angebot);
|
||
const kam = await signalSenden(anderer, { typ: 'angebot', sdp: d.pc.localDescription });
|
||
if (!kam) {
|
||
/* NIEMAND HAT ZUGEHOERT. Die halbfertige Verbindung muss weg,
|
||
sonst sagt der nächste Takt „den kenne ich schon" und ruft
|
||
nie wieder an -- das Bild bliebe für immer schwarz.
|
||
Gemessen: genau das ist passiert, weil der Ereignisstrom
|
||
des Empfängers ein paar hundert Millisekunden nach seiner
|
||
Anmeldung aufgeht. */
|
||
try { d.pc.close(); } catch { /* egal */ }
|
||
drähte.delete(anderer);
|
||
}
|
||
} catch (f) {
|
||
console.error('[reaktion] Anruf:', f && f.message);
|
||
const d = drähte.get(anderer);
|
||
if (d) { try { d.pc.close(); } catch { /* egal */ } drähte.delete(anderer); }
|
||
} finally {
|
||
clearTimeout(notbremse);
|
||
ruftGerade.delete(anderer);
|
||
}
|
||
}
|
||
|
||
async function signalEmpfangen(von, daten) {
|
||
try {
|
||
if (daten.typ === 'bitte-anrufen') {
|
||
/* Jemand sagt: „Ich bin jetzt bereit, ruf mich an."
|
||
Nur wer sendet, kann dem nachkommen -- und nur, wenn er
|
||
nach der Regel ohnehin der Anrufer waere. Sonst haette man
|
||
zwei Anrufer und genau das Durcheinander zurueck, das die
|
||
Regel verhindert. */
|
||
if (!binSender || !ichRufeAn(von)) return;
|
||
|
||
/* ES LAEUFT SCHON. Der Bittende weiss das nicht -- er hat ja
|
||
noch nichts -- und fragt deshalb alle drei Sekunden weiter.
|
||
Wer hier weitermacht, wirft seine eigene, halbfertige
|
||
Verhandlung weg und fängt ewig von vorn an. */
|
||
if (ruftGerade.has(von)) return;
|
||
const alt = drähte.get(von);
|
||
if (alt && verhandeltNoch(alt)) return;
|
||
|
||
if (alt) {
|
||
/* Jetzt ist es wirklich eine Leiche: fertig verhandelt und
|
||
trotzdem ohne Bild. Weg damit. */
|
||
try { alt.pc.close(); } catch { /* egal */ }
|
||
drähte.delete(von);
|
||
}
|
||
anrufen(von);
|
||
return;
|
||
}
|
||
if (daten.typ === 'angebot') {
|
||
/* ==== ERST NACHSEHEN, DANN ANTWORTEN ====================
|
||
|
||
Halte ich mich fuer einen Zuschauer, kann das eine Sekunde
|
||
alt sein: Wer gerade als Gast dazugeholt wurde, bekommt
|
||
den Anruf des Hosts oft frueher als seine eigene neue
|
||
Lage. Er baute dann eine Verbindung OHNE Spur, antwortete
|
||
freundlich -- und beim Host blieb das Fenster schwarz.
|
||
|
||
Eine Anfrage in genau dem Moment, in dem ohnehin etwas
|
||
Neues passiert. Die Alternative waere, sechs Sekunden auf
|
||
den Wachhund zu warten; den behalten wir als Netz, aber
|
||
er soll nicht der Normalfall sein. */
|
||
if (!binSender && lage && lage.stand === 'live') {
|
||
/* SANFT (30.09.2026): Diese Abfrage macht die Seite von
|
||
sich aus, bei jedem Verbindungssignal. Kommt während
|
||
ihrer Laufzeit ein Rundruf, ist der neuer.
|
||
|
||
Gemessen hat sie den frisch gesetzten Wechsel der
|
||
Werbung wieder auf Laufband zurückgedreht — gefunden hat
|
||
das nicht das Nachdenken, sondern eine Mitschrift der
|
||
Aufrufkette: `zeichnen < standHolen < signalEmpfangen`.
|
||
Drei Runden Raten davor lagen daneben. */
|
||
await standHolen(true).catch(() => { /* dann eben mit dem alten Stand */ });
|
||
}
|
||
if (binSender && !meinStrom) await kameraHolen();
|
||
const d = draht(von);
|
||
await d.pc.setRemoteDescription(daten.sdp);
|
||
const antwort = await d.pc.createAnswer();
|
||
await d.pc.setLocalDescription(antwort);
|
||
signalSenden(von, { typ: 'antwort', sdp: d.pc.localDescription });
|
||
} else if (daten.typ === 'antwort') {
|
||
const d = drähte.get(von);
|
||
/* NUR ANNEHMEN, WENN AUCH GEWARTET WIRD. `have-local-offer`
|
||
heisst: Ich habe gefragt und warte auf die Antwort. Steht
|
||
dort `stable`, ist die Verbindung längst fertig -- eine
|
||
zweite Antwort ist dann ein Nachzügler und kein Ereignis.
|
||
Ohne diese Zeile wirft der Browser, und der Wurf reisst die
|
||
ganze Signalbehandlung mit. */
|
||
if (d && d.pc.signalingState === 'have-local-offer') {
|
||
await d.pc.setRemoteDescription(daten.sdp);
|
||
}
|
||
} else if (daten.typ === 'eis') {
|
||
const d = drähte.get(von);
|
||
if (d && daten.eis) await d.pc.addIceCandidate(daten.eis).catch(() => {});
|
||
}
|
||
} catch (f) {
|
||
console.error('[reaktion] Signal:', f && f.message);
|
||
}
|
||
}
|
||
|
||
/** Nach jeder Änderung: zu allen verbinden, die dazugekommen sind,
|
||
* und alle abbauen, die weg sind. */
|
||
function draehteNachziehen() {
|
||
if (!lage || lage.stand !== 'live') return;
|
||
if (!binSender && !lage.ich.sicht) return; // kein Kameraplatz: nur Ton und Video
|
||
|
||
const sollen = new Set();
|
||
if (binSender) {
|
||
/* Wer sendet, ruft ALLE an, die zusehen -- und die anderen
|
||
Sender dazu. Den anderen Sender ruft nur der mit der
|
||
kleineren Nummer (siehe Kopf), sonst rufen beide.
|
||
|
||
Dass ein Gast die Liste der Zusehenden überhaupt bekommt,
|
||
ist kein Zufall: Ohne sie wüsste er nicht, wen er anrufen
|
||
soll, und weil Zuschauer grundsätzlich nie anrufen, käme
|
||
sein Bild bei niemandem an. */
|
||
if (lage.host && lage.host.id !== meineId) sollen.add(lage.host.id);
|
||
for (const g of lage.gaeste) if (g.id !== meineId) sollen.add(g.id);
|
||
if (lage.zuschauer) for (const z of lage.zuschauer) if (z.sicht && z.id !== meineId) sollen.add(z.id);
|
||
} else {
|
||
if (lage.host) sollen.add(lage.host.id);
|
||
for (const g of lage.gaeste) sollen.add(g.id);
|
||
}
|
||
|
||
/* ERST AUFRAEUMEN, DANN ANRUFEN -- und nicht umgekehrt.
|
||
|
||
Stand die Reihenfolge andersherum, merkte der Aufräumer eine
|
||
tote Leitung zwar, aber der Anrufer war schon vorbei: Neu
|
||
angerufen wurde erst im nächsten Durchgang, zweieinhalb
|
||
Sekunden später. Jemand sieht in dieser Zeit ein schwarzes
|
||
Fenster und weiß nicht, ob es noch kommt. */
|
||
for (const [id, d] of [...drähte]) {
|
||
if (!sollen.has(id)) {
|
||
try { d.pc.close(); } catch { /* egal */ }
|
||
drähte.delete(id);
|
||
continue;
|
||
}
|
||
/* ==== DIE STILLE LEITUNG ====================================
|
||
|
||
Von einem SENDER erwarte ich ein Bild. Kommt nach neun
|
||
Sekunden keins, ist der Draht tot -- auch wenn der Browser
|
||
ihn „connecting" nennt. Er wird weggeworfen, und der
|
||
nächste Durchgang ruft neu an.
|
||
|
||
WARUM NEUN SEKUNDEN: Ein Verbindungsaufbau über STUN
|
||
braucht im eigenen Netz unter einer Sekunde, über TURN
|
||
selten mehr als drei. Neun ist reichlich und trotzdem kurz
|
||
genug, dass es niemandem auffällt.
|
||
|
||
WARUM NUR BEI SENDERN: Ein Zuschauer schickt nichts. Bei
|
||
ihm ist „kein Bild" der Normalfall und kein Befund. */
|
||
/* ==== WER NOCH VERHANDELT, WIRD IN RUHE GELASSEN =========
|
||
|
||
Zwei Zeichen dafuer, und beide muessen gelten:
|
||
|
||
`ruftGerade` -- ein Angebot ist unterwegs. Zwischen dem
|
||
Anlegen der Leitung und dem Absenden liegen drei await;
|
||
genau darin hat der Wachhund zugeschlagen.
|
||
|
||
`signalingState !== 'stable'` -- Angebot und Antwort sind
|
||
noch nicht beide da. Eine Leitung ohne Bild ist in diesem
|
||
Zustand kein Befund, sondern der Normalfall. */
|
||
if (ruftGerade.has(id) || verhandeltNoch(d)) continue;
|
||
|
||
const erwarteBild = istSender(id);
|
||
const zustand = d.pc.connectionState;
|
||
/* SECHS SEKUNDEN. Ein Aufbau über STUN braucht im eigenen Netz
|
||
unter einer Sekunde, über TURN selten mehr als drei. Sechs
|
||
ist reichlich -- und kurz genug, dass niemand anfängt, an
|
||
seinem Browser zu zweifeln. */
|
||
const langGenug = Date.now() - d.seit > 6000;
|
||
const tot = (erwarteBild && !d.strom && langGenug)
|
||
|| (zustand === 'disconnected' && langGenug)
|
||
/* Steckengeblieben: nicht mehr in Verhandlung (die Uhr ist
|
||
abgelaufen) und trotzdem nicht fertig. Ohne diese Zeile
|
||
bleibt so eine Leitung ewig stehen und blockiert den
|
||
Platz. */
|
||
|| d.pc.signalingState !== 'stable';
|
||
if (tot) {
|
||
console.warn('[reaktion] Stille Leitung zu', id, '(' + zustand + ') -- neu aufbauen.');
|
||
try { d.pc.close(); } catch { /* egal */ }
|
||
drähte.delete(id);
|
||
/* Auch die Bremse lösen: Sonst wartet die nächste Bitte
|
||
noch drei Sekunden, obwohl gerade etwas kaputtging. */
|
||
zuletztGebeten.delete(id);
|
||
}
|
||
}
|
||
|
||
for (const id of sollen) {
|
||
if (drähte.has(id) || ruftGerade.has(id)) continue;
|
||
if (ichRufeAn(id)) anrufen(id);
|
||
else bitten(id);
|
||
}
|
||
kamerasZeichnen();
|
||
}
|
||
|
||
/* Hoechstens alle drei Sekunden je Gegenueber -- eine Bitte, die
|
||
jede Sekunde wiederholt wird, ist eine Flut und keine Bitte. */
|
||
const zuletztGebeten = new Map();
|
||
function bitten(anderer) {
|
||
const jetzt2 = Date.now();
|
||
if (jetzt2 - (zuletztGebeten.get(anderer) || 0) < 3000) return;
|
||
zuletztGebeten.set(anderer, jetzt2);
|
||
signalSenden(anderer, { typ: 'bitte-anrufen' });
|
||
}
|
||
|
||
/** Alle Leitungen weg, die Kamera bleibt.
|
||
*
|
||
* Unterschied zu `draehteWeg()`: Dort endet die Sendung, hier
|
||
* fängt sie für mich erst an. Die Kamera noch einmal freigeben zu
|
||
* lassen wäre eine zweite Abfrage im Browser -- und die kommt
|
||
* mitten in der Sendung. */
|
||
function leitungenNeu() {
|
||
for (const [, d] of drähte) { try { d.pc.close(); } catch { /* egal */ } }
|
||
drähte.clear();
|
||
ruftGerade.clear();
|
||
zuletztGebeten.clear();
|
||
}
|
||
|
||
function draehteWeg() {
|
||
for (const [, d] of drähte) { try { d.pc.close(); } catch { /* egal */ } }
|
||
drähte.clear();
|
||
ruftGerade.clear();
|
||
zuletztGebeten.clear();
|
||
if (meinStrom) {
|
||
for (const s of meinStrom.getTracks()) s.stop();
|
||
meinStrom = null;
|
||
}
|
||
}
|
||
|
||
/* =================================================================
|
||
DER LIVE-CHAT
|
||
================================================================= */
|
||
/* ---- Die Chat-Stufe ----------------------------------------------
|
||
Drei Schalter fuer die zwei, die die Sendung fuehren; fuer alle
|
||
anderen die Auskunft, warum das Feld gerade nicht geht.
|
||
|
||
EIN GESPERRTES FELD MUSS SAGEN, WARUM. Ein Feld, das sich nicht
|
||
beschreiben laesst und schweigt, sieht kaputt aus -- und dann
|
||
schreibt jemand in den Hauschat, dass die Reaction hakt. */
|
||
function chatStufeZeichnen() {
|
||
const stufen = $('chat-stufen');
|
||
if (!stufen) return;
|
||
stufen.hidden = !lage.ich.host;
|
||
if (lage.ich.host) {
|
||
for (const k of stufen.children) {
|
||
k.setAttribute('aria-pressed', String(k.dataset.modus === lage.chat_modus));
|
||
}
|
||
}
|
||
|
||
meldungenZeichnen();
|
||
|
||
const feld = $('chat-feld');
|
||
const senden = document.querySelector('.schiene__senden');
|
||
const darf = lage.ich.darf_schreiben !== false;
|
||
feld.disabled = !darf;
|
||
if (senden) senden.disabled = !darf;
|
||
/* DER GRUND KOMMT VOM SERVER, er wird hier nicht erraten.
|
||
Vorher stand hier eine Ableitung aus der Chat-Stufe -- und die
|
||
war für jemanden, der stummgeschaltet war, während der Chat
|
||
offen stand, schlicht falsch („Der Chat ist gerade zu"). Wer
|
||
die Antwort hat, muss sie nicht rekonstruieren. */
|
||
const GRUENDE = {
|
||
stumm_geschaltet: 'Du bist gerade stumm geschaltet',
|
||
aus_der_sendung: 'Du bist aus dieser Sendung',
|
||
treff_massnahme: 'Im Treff läuft eine Maßnahme gegen dich',
|
||
chat_nur_team: 'Gerade schreibt nur das Team',
|
||
chat_zu: 'Der Chat ist gerade zu',
|
||
saal_zu: 'Der Saal ist zu',
|
||
};
|
||
feld.placeholder = darf
|
||
? 'Sag was …'
|
||
: (GRUENDE[lage.ich.schreib_grund] || 'Gerade geht das nicht');
|
||
$('schiene').dataset.chat = lage.chat_modus || 'offen';
|
||
}
|
||
|
||
async function chatLaden() {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/chat');
|
||
if (!a.ok) return;
|
||
const d = await a.json();
|
||
const liste = $('chat-liste');
|
||
liste.textContent = '';
|
||
for (const b of d.beitraege) liste.append(beitragBauen(b));
|
||
liste.scrollTop = liste.scrollHeight;
|
||
$('chat-zahl').textContent = String(d.beitraege.length);
|
||
} catch { /* der Chat kommt gleich per Strom nach */ }
|
||
}
|
||
|
||
/* ==== WER SPRICHT ================================================
|
||
|
||
Filipe: „die modis, linke hand und rechte hand soll im chat
|
||
extra aussehen."
|
||
|
||
EIN ABZEICHEN, KEINE FARBE ALLEIN. Farbe allein ist für rund
|
||
acht Prozent der Männer keine Auskunft -- und im Live-Chat ist
|
||
genau das die Frage, auf die es ankommt: Ist das jemand vom
|
||
Team oder irgendwer?
|
||
|
||
Die Wörter sind kurz, weil die Schiene schmal ist, und sie
|
||
sagen den RANG nicht. „Team" für die Hände, „Modi" für die
|
||
Moderation, „Dogi" für den, der sendet. Wer was darf, steht in
|
||
der Rechtetafel; hier geht es nur darum, wen man vor sich hat. */
|
||
const ABZEICHEN = {
|
||
admin: ['Dogi', 'dogi'],
|
||
hand: ['Team', 'team'],
|
||
linke: ['Team', 'team'],
|
||
modi: ['Modi', 'modi'],
|
||
};
|
||
|
||
function beitragBauen(b) {
|
||
const li = el('li', 'beitrag');
|
||
li.dataset.id = String(b.id);
|
||
if (lage && lage.host && b.person_id === lage.host.id) li.dataset.host = 'ja';
|
||
else if (b.rolle && b.rolle !== 'gast') li.dataset.team = 'ja';
|
||
|
||
const kopf = el('div', 'beitrag__wer');
|
||
kopf.append(el('span', null, b.name));
|
||
|
||
const abz = ABZEICHEN[b.rolle];
|
||
if (abz) {
|
||
const marke = el('span', 'beitrag__rolle', abz[0]);
|
||
marke.dataset.rolle = abz[1];
|
||
kopf.append(marke);
|
||
}
|
||
|
||
/* Wegnehmen darf, wem der Beitrag gehört -- und wer moderiert.
|
||
Der Knopf steht nur dort, wo er auch wirkt; ein Knopf, der mit
|
||
403 antwortet, ist eine Falle. */
|
||
const darf = lage && (lage.ich.moderiert || b.person_id === lage.ich.id);
|
||
if (darf) {
|
||
const weg = el('button', 'beitrag__weg', 'weg');
|
||
weg.type = 'button';
|
||
weg.addEventListener('click', () => beitragWeg(b.id));
|
||
kopf.append(weg);
|
||
}
|
||
|
||
/* MELDEN DARF JEDER -- ausser bei sich selbst.
|
||
Das ist der Punkt daran: Die Moderation sieht nicht alles, wer
|
||
mitliest schon. Ohne einen Weg für die Zusehenden bliebe
|
||
Moderation auf das beschränkt, was zufällig jemand vom Team
|
||
gerade gelesen hat. */
|
||
if (lage && b.person_id !== lage.ich.id && !lage.ich.moderiert) {
|
||
const m = el('button', 'beitrag__melden', 'melden');
|
||
m.type = 'button';
|
||
m.title = 'Der Moderation zeigen';
|
||
m.addEventListener('click', () => beitragMelden(b.id, m));
|
||
kopf.append(m);
|
||
}
|
||
|
||
/* DIE MODERATION GREIFT AM NAMEN AN. So machen es alle großen
|
||
Chats, und aus gutem Grund: Man sieht den Beitrag, will etwas
|
||
gegen die PERSON tun, und der Name ist das, worauf man
|
||
ohnehin zeigt. */
|
||
if (lage && lage.ich.moderiert && b.person_id && b.person_id !== lage.ich.id) {
|
||
kopf.firstChild.replaceWith(namensknopf(b));
|
||
}
|
||
|
||
li.append(kopf);
|
||
li.append(el('div', null, b.text));
|
||
return li;
|
||
}
|
||
|
||
function namensknopf(b) {
|
||
const k = el('button', 'beitrag__name', b.name);
|
||
k.type = 'button';
|
||
k.title = 'Was tun?';
|
||
k.addEventListener('click', () => moderationsMenue(k, b));
|
||
return k;
|
||
}
|
||
|
||
/** Das kleine Menü am Namen. Drei Dinge, mehr nicht.
|
||
*
|
||
* WARUM NICHT MEHR: Im Live entscheidet man in Sekunden. Eine
|
||
* Liste mit sieben Möglichkeiten liest niemand zu Ende -- und was
|
||
* länger gelten soll als diese Sendung, gehört ohnehin in die
|
||
* Maßnahmen des Treffs, wo es eine Begründungspflicht gibt. Der
|
||
* Weg dorthin steht als letzter Punkt. */
|
||
function moderationsMenue(knopf, b) {
|
||
document.querySelector('.modmenue')?.remove();
|
||
const menue = el('div', 'modmenue');
|
||
menue.setAttribute('role', 'menu');
|
||
|
||
const tun = (wort, was) => {
|
||
const k = el('button', 'modmenue__knopf', wort);
|
||
k.type = 'button';
|
||
k.addEventListener('click', async () => { menue.remove(); await was(); });
|
||
menue.append(k);
|
||
};
|
||
|
||
tun('Beitrag wegnehmen', () => beitragWeg(b.id));
|
||
tun('10 Minuten stumm', () => massnahme(b.person_id, 'stumm', 10));
|
||
tun('Aus der Sendung', async () => {
|
||
const ja = window.frageNach
|
||
? await window.frageNach({
|
||
titel: `${b.name} aus der Sendung nehmen?`,
|
||
was: 'Die Person kann bis zum Ende der Sendung nicht mehr zusehen.',
|
||
bleibt: 'Für länger nimm die Maßnahmen im Treff — die gelten über '
|
||
+ 'diese Sendung hinaus.',
|
||
ja: 'Rausnehmen', nein: 'Doch nicht',
|
||
})
|
||
: false;
|
||
if (ja) massnahme(b.person_id, 'raus');
|
||
});
|
||
|
||
const treff = el('a', 'modmenue__knopf modmenue__knopf--weg', 'Im Treff sperren …');
|
||
treff.href = '/workspace/treff-moderation.html';
|
||
treff.target = '_blank';
|
||
treff.rel = 'noopener';
|
||
menue.append(treff);
|
||
|
||
menueHinstellen(menue, knopf);
|
||
}
|
||
|
||
/* ==== WARUM DAS MENUE AM KOERPER HAENGT UND NICHT AM KNOPF =========
|
||
|
||
GEMESSEN, NICHT ÜBERLEGT. Zuerst stand es als Kind der
|
||
Beitragszeile da, mit `position: absolute`. Die Rechnung ging
|
||
auf: 216 px breit, x=1099..1315 im 1440er Fenster, y=652..840 bei
|
||
900 px Höhe — komplett im Bild. Das Bildschirmfoto zeigte
|
||
trotzdem einen abgeschnittenen Streifen hinter dem Eingabefeld.
|
||
|
||
Der Grund: Die Chatliste rollt (`overflow-y: auto`). Ein
|
||
absolut gesetztes Kind wird von einem rollenden Vorfahren
|
||
BESCHNITTEN, ganz gleich, wo es im Fenster liegt. „Im Fenster"
|
||
und „sichtbar" sind zwei verschiedene Fragen, und ich hatte die
|
||
falsche gemessen.
|
||
|
||
Also: fest am Fenster (`position: fixed`), Lage aus dem
|
||
tatsächlichen Rechteck des Knopfes gerechnet, und wo unten oder
|
||
rechts kein Platz ist, klappt es nach oben bzw. nach links. Und
|
||
es geht zu, sobald jemand rollt — sonst stünde es gleich neben
|
||
einem ganz anderen Namen. */
|
||
function menueHinstellen(menue, anker) {
|
||
menue.style.position = 'fixed';
|
||
menue.style.visibility = 'hidden';
|
||
document.body.append(menue);
|
||
|
||
const a = anker.getBoundingClientRect();
|
||
const m = menue.getBoundingClientRect();
|
||
const luft = 8;
|
||
|
||
let links = a.left;
|
||
if (links + m.width > window.innerWidth - luft) links = window.innerWidth - m.width - luft;
|
||
if (links < luft) links = luft;
|
||
|
||
let oben = a.bottom + 6;
|
||
/* Passt es unten nicht, klappt es über den Knopf. Passt es auch
|
||
dort nicht (kleines Fenster, langes Menü), wird es oben
|
||
angesetzt und rollt in sich selbst. */
|
||
if (oben + m.height > window.innerHeight - luft) {
|
||
const darueber = a.top - m.height - 6;
|
||
oben = darueber >= luft ? darueber : luft;
|
||
}
|
||
menue.style.left = `${Math.round(links)}px`;
|
||
menue.style.top = `${Math.round(oben)}px`;
|
||
menue.style.maxHeight = `${Math.round(window.innerHeight - oben - luft)}px`;
|
||
menue.style.visibility = '';
|
||
|
||
const zumachen = () => {
|
||
menue.remove();
|
||
document.removeEventListener('click', beiKlick, true);
|
||
window.removeEventListener('resize', zumachen);
|
||
document.removeEventListener('scroll', zumachen, true);
|
||
};
|
||
function beiKlick(e) { if (!menue.contains(e.target) && e.target !== anker) zumachen(); }
|
||
setTimeout(() => {
|
||
document.addEventListener('click', beiKlick, true);
|
||
window.addEventListener('resize', zumachen);
|
||
/* `true` -- Rollen steigt nicht auf, es muss beim Einfangen
|
||
abgeholt werden, sonst merkt man das Rollen der Chatliste
|
||
nicht. */
|
||
document.addEventListener('scroll', zumachen, true);
|
||
}, 0);
|
||
}
|
||
|
||
async function massnahme(personId, art, minuten) {
|
||
const a = await hole('/workspace/api/reaktion/massnahme', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ person_id: personId, art, minuten }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
sagFehler(art === 'stumm' ? 'Für 10 Minuten stumm.' : 'Aus der Sendung genommen.');
|
||
}
|
||
|
||
async function beitragMelden(id, knopf) {
|
||
const a = await hole(`/workspace/api/reaktion/chat/${id}/melden`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
knopf.textContent = 'gemeldet';
|
||
knopf.disabled = true;
|
||
}
|
||
|
||
/* ---- Die Meldungen im Kopf der Schiene ----------------------------
|
||
Nur für die Moderation, und nur, wenn etwas offen ist. Eine Zahl,
|
||
die immer dasteht und meistens null ist, wird nach drei Tagen
|
||
nicht mehr gelesen. */
|
||
function meldungenZeichnen() {
|
||
const knopf = $('chat-meldungen');
|
||
if (!knopf) return;
|
||
const offen = lage.meldungen || [];
|
||
knopf.hidden = !lage.ich.moderiert || offen.length === 0;
|
||
knopf.textContent = offen.length === 1 ? '1 Meldung' : `${offen.length} Meldungen`;
|
||
}
|
||
|
||
function meldungenZeigen() {
|
||
const offen = lage.meldungen || [];
|
||
if (!offen.length) return;
|
||
document.querySelector('.modmenue')?.remove();
|
||
const menue = el('div', 'modmenue modmenue--liste');
|
||
for (const m of offen) {
|
||
const zeile = el('div', 'modmeldung');
|
||
zeile.append(el('span', 'modmeldung__wer', `${m.wer} — gemeldet von ${m.melder || 'jemandem'}`));
|
||
zeile.append(el('span', 'modmeldung__text', m.text));
|
||
if (m.weg_am) zeile.append(el('span', 'modmeldung__schon', 'schon weggenommen'));
|
||
const reihe = el('div', 'modmeldung__knoepfe');
|
||
|
||
if (!m.weg_am) {
|
||
const weg = el('button', 'modmenue__knopf', 'Wegnehmen');
|
||
weg.type = 'button';
|
||
weg.addEventListener('click', async () => {
|
||
await beitragWeg(m.beitrag_id);
|
||
await erledigt(m.id);
|
||
});
|
||
reihe.append(weg);
|
||
}
|
||
const stumm = el('button', 'modmenue__knopf', '10 Min stumm');
|
||
stumm.type = 'button';
|
||
stumm.addEventListener('click', async () => {
|
||
await massnahme(m.wer_id, 'stumm', 10);
|
||
await erledigt(m.id);
|
||
});
|
||
reihe.append(stumm);
|
||
|
||
const ok = el('button', 'modmenue__knopf modmenue__knopf--weg', 'Passt schon');
|
||
ok.type = 'button';
|
||
ok.addEventListener('click', () => erledigt(m.id));
|
||
reihe.append(ok);
|
||
zeile.append(reihe);
|
||
menue.append(zeile);
|
||
}
|
||
menueHinstellen(menue, $('chat-meldungen'));
|
||
}
|
||
|
||
async function erledigt(id) {
|
||
const a = await hole(`/workspace/api/reaktion/meldung/${id}/erledigt`, { method: 'POST' });
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
document.querySelector('.modmenue')?.remove();
|
||
zeichnen();
|
||
}
|
||
|
||
async function beitragWeg(id) {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/chat/' + id, { method: 'DELETE' });
|
||
if (!a.ok) melde(await a.json().catch(() => null), 'Wegnehmen ging nicht.');
|
||
} catch { /* der Strom holt den Stand nach */ }
|
||
}
|
||
|
||
function chatAnhaengen(b) {
|
||
const liste = $('chat-liste');
|
||
/* Nur nachfahren, wenn man ohnehin unten stand. Wer nach oben
|
||
gescrollt hat, liest etwas -- und soll nicht bei jedem neuen
|
||
Satz herausgerissen werden. */
|
||
const unten = liste.scrollHeight - liste.scrollTop - liste.clientHeight < 60;
|
||
liste.append(beitragBauen(b));
|
||
while (liste.children.length > 200) liste.firstElementChild.remove();
|
||
$('chat-zahl').textContent = String(liste.children.length);
|
||
if (unten) liste.scrollTop = liste.scrollHeight;
|
||
}
|
||
|
||
/* =================================================================
|
||
DIE REGIE
|
||
|
||
Sie ist nach dem Vorbild eines Regieplatzes gebaut, nicht nach
|
||
dem eines Formulars. Vier Dinge machen den Unterschied:
|
||
|
||
1. Eine Leiste, die IMMER steht -- Lampe, Laufzeit, Zahlen und
|
||
die drei grossen Knoepfe liegen ausserhalb des
|
||
einklappbaren Teils.
|
||
2. Register statt Stapel.
|
||
3. Eine Videospur mit echter Bedienung.
|
||
4. PEGEL an den Reglern. Sie beantworten die Frage, die man
|
||
sich sonst erst nach der Sendung stellt: „War mein Mikro
|
||
ueberhaupt an?" Ein Regler auf 100 sagt darueber nichts --
|
||
er sagt nur, wo der Knopf steht.
|
||
================================================================= */
|
||
const REGLER = [
|
||
/* `schalter: true` heisst: kein Schieber, nur an/aus und Pegel.
|
||
Die eigene Lautstaerke laesst sich nicht am Regler aendern --
|
||
dafuer muesste der Ton umgerechnet und die Spur in jeder
|
||
Verbindung ausgetauscht werden. Ein Schieber, der sich bewegen
|
||
laesst und nichts tut, ist schlimmer als keiner. */
|
||
{ schluessel: 'mikro', wort: 'Mein Mikro', schalter: true },
|
||
{ schluessel: 'gast1', wort: 'Gast 1' },
|
||
{ schluessel: 'gast2', wort: 'Gast 2' },
|
||
{ schluessel: 'video', wort: 'YouTube' },
|
||
];
|
||
/* Die Regler-Staende. Sie stehen HIER und nicht im Pult: Der
|
||
YouTube-Regler gilt auch fuer jemanden, der gar kein Pult hat --
|
||
`tonAn()` setzt damit die Lautstaerke des Players. */
|
||
const staende = { mikro: 100, gast1: 100, gast2: 100, video: 100 };
|
||
|
||
/* Solange jemand am Positionsband zieht, wird es NICHT von der Uhr
|
||
ueberschrieben -- sonst rutscht der Griff unter dem Finger weg. */
|
||
let bandZieht = false;
|
||
|
||
/* ---- Die Pegel ---------------------------------------------------
|
||
Gemessen wird mit der Klanganalyse des Browsers: Ein
|
||
`AnalyserNode` je Quelle, alle 90 ms der lauteste Ausschlag.
|
||
|
||
DER YOUTUBE-REGLER HAT KEINEN ECHTEN PEGEL, und das steht auch
|
||
dabei. Der Ton kommt aus einem fremden Rahmen; ihn zu messen
|
||
hiesse, in eine fremde Seite zu greifen, und das laesst kein
|
||
Browser zu. Dort zeigt der Balken deshalb grau die EINGESTELLTE
|
||
Lautstaerke. Einen erfundenen Ausschlag zu zeichnen waere
|
||
schlimmer als keiner -- man wuerde ihm glauben. */
|
||
let klangRaum = null;
|
||
let q2spitze = 0; // die Spitze fuer die Senderleiste
|
||
const pegelQuellen = new Map(); // schluessel -> { analyse, feld, spitze }
|
||
|
||
function klangRaumHolen() {
|
||
if (klangRaum) return klangRaum;
|
||
const Bau = window.AudioContext || window.webkitAudioContext;
|
||
if (!Bau) return null;
|
||
try { klangRaum = new Bau(); } catch { return null; }
|
||
return klangRaum;
|
||
}
|
||
|
||
function pegelAnschliessen(schluessel, strom) {
|
||
if (!strom || !strom.getAudioTracks || !strom.getAudioTracks().length) return;
|
||
const raum = klangRaumHolen();
|
||
if (!raum) return;
|
||
const vorher = pegelQuellen.get(schluessel);
|
||
if (vorher && vorher.strom === strom) return;
|
||
try {
|
||
const quelle = raum.createMediaStreamSource(strom);
|
||
const analyse = raum.createAnalyser();
|
||
analyse.fftSize = 512;
|
||
analyse.smoothingTimeConstant = .5;
|
||
quelle.connect(analyse);
|
||
/* NICHT an den Lautsprecher weiterreichen. Der Ton laeuft
|
||
ueber das Videoelement; ein zweiter Weg hiesse, alles
|
||
doppelt zu hoeren. */
|
||
pegelQuellen.set(schluessel, {
|
||
strom, analyse, feld: new Uint8Array(analyse.frequencyBinCount), spitze: 0,
|
||
});
|
||
} catch { /* dann bleibt der Balken dunkel, und das ist ehrlich */ }
|
||
}
|
||
|
||
function pegelTakt() {
|
||
/* Der Pegel in der Senderleiste -- fuer jeden, der sendet. Ein
|
||
Gast hat kein Pult, an dem er nachsehen koennte, ob sein Ton
|
||
ankommt; er braucht ihn am dringendsten. */
|
||
const leistenPegel = $('s-pegel');
|
||
if (leistenPegel) {
|
||
const q = pegelQuellen.get('mikro');
|
||
let hoch = 0;
|
||
if (q && !(lage && lage.ich.mikro_aus)) {
|
||
q.analyse.getByteTimeDomainData(q.feld);
|
||
let summe = 0;
|
||
for (let i = 0; i < q.feld.length; i++) {
|
||
const w = (q.feld[i] - 128) / 128;
|
||
summe += w * w;
|
||
}
|
||
hoch = Math.min(100, Math.round(Math.sqrt(Math.sqrt(summe / q.feld.length)) * 165));
|
||
}
|
||
q2spitze = hoch > q2spitze ? hoch : Math.max(hoch, q2spitze - 7);
|
||
leistenPegel.style.setProperty('--stand', q2spitze + '%');
|
||
}
|
||
|
||
for (const r of REGLER) {
|
||
const kasten = document.querySelector(`.regler[data-was="${r.schluessel}"]`);
|
||
if (!kasten) continue;
|
||
const balken = kasten.querySelector('.pegel');
|
||
if (!balken) continue;
|
||
|
||
if (r.schluessel === 'video') {
|
||
/* KEIN MESSWERT -- die eingestellte Lautstärke, und zwar
|
||
GRAU. Im ersten Bild stand hier ein grün-gelb-roter
|
||
Vollausschlag, und der sah aus wie ein Signal. Genau das
|
||
sollte er nie: Einen erfundenen Pegel zu zeichnen ist
|
||
schlimmer als keiner, weil man ihm glaubt. */
|
||
const aus = kasten.dataset.stumm === 'ja';
|
||
balken.dataset.still = 'ja';
|
||
balken.style.setProperty('--stand', (aus ? 0 : staende.video) + '%');
|
||
continue;
|
||
}
|
||
|
||
const q = pegelQuellen.get(r.schluessel);
|
||
if (!q) { balken.style.setProperty('--stand', '0%'); continue; }
|
||
q.analyse.getByteTimeDomainData(q.feld);
|
||
let summe = 0;
|
||
for (let i = 0; i < q.feld.length; i++) {
|
||
const w = (q.feld[i] - 128) / 128;
|
||
summe += w * w;
|
||
}
|
||
const mittel = Math.sqrt(summe / q.feld.length);
|
||
/* Auf eine Skala, die dem Ohr entspricht: leise Sprache soll
|
||
sichtbar ausschlagen, nicht erst Geschrei. */
|
||
const hoch = Math.min(100, Math.round(Math.sqrt(mittel) * 165));
|
||
/* Schnell hoch, langsam runter -- ein Balken, der sofort
|
||
abfaellt, flackert und ist unlesbar. */
|
||
q.spitze = hoch > q.spitze ? hoch : Math.max(hoch, q.spitze - 7);
|
||
balken.style.setProperty('--stand', q.spitze + '%');
|
||
balken.dataset.still = kasten.dataset.stumm === 'ja' ? 'ja' : 'nein';
|
||
}
|
||
}
|
||
|
||
/** Woher kommt der Ton fuer welchen Regler? */
|
||
function pegelNachziehen() {
|
||
if (meinStrom) pegelAnschliessen('mikro', meinStrom);
|
||
const fremde = [...document.querySelectorAll('#kameras .kamera')]
|
||
.filter((k) => k.dataset.wer !== String(meineId));
|
||
fremde.forEach((k, i) => {
|
||
if (i > 1) return;
|
||
const v = k.querySelector('video');
|
||
if (v && v.srcObject) pegelAnschliessen(i === 0 ? 'gast1' : 'gast2', v.srcObject);
|
||
});
|
||
}
|
||
|
||
/* ---- Was die eigene Leitung traegt --------------------------------
|
||
GEMESSEN, NICHT GERECHNET. „Zwoelf mal 350 kbit/s" ist eine
|
||
Schaetzung aus der Planung; was wirklich hinausgeht, weiss nur
|
||
die Verbindung selbst. Der Unterschied zwischen „es ruckelt bei
|
||
euch?" und „ich sehe, dass es zu viel wird" sind genau diese
|
||
zwei Zeilen. */
|
||
let letzteBytes = 0;
|
||
let letzteZeit = 0;
|
||
async function lastMessen() {
|
||
if (!drähte.size) {
|
||
letzteBytes = 0; letzteZeit = 0;
|
||
const f = $('z-last'); if (f) f.textContent = '–';
|
||
return;
|
||
}
|
||
let bytes = 0;
|
||
for (const { pc } of drähte.values()) {
|
||
try {
|
||
const bericht = await pc.getStats();
|
||
bericht.forEach((e) => {
|
||
if (e.type === 'outbound-rtp' && !e.isRemote) bytes += e.bytesSent || 0;
|
||
});
|
||
} catch { /* eine Verbindung im Aufbau hat noch keine Zahlen */ }
|
||
}
|
||
const jetzt = Date.now();
|
||
if (letzteZeit && jetzt > letzteZeit && bytes >= letzteBytes) {
|
||
const bit = (bytes - letzteBytes) * 8 / ((jetzt - letzteZeit) / 1000);
|
||
const f = $('z-last');
|
||
if (f) {
|
||
f.textContent = bit > 900_000
|
||
? (bit / 1_000_000).toFixed(1).replace('.', ',') + ' Mbit/s'
|
||
: Math.round(bit / 1000) + ' kbit/s';
|
||
}
|
||
}
|
||
letzteBytes = bytes; letzteZeit = jetzt;
|
||
}
|
||
|
||
/* ---- Zeiten in Worte --------------------------------------------- */
|
||
function alsZeit(sekunden) {
|
||
const s = Math.max(0, Math.floor(sekunden || 0));
|
||
const st = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const r = s % 60;
|
||
const zwei = (n) => String(n).padStart(2, '0');
|
||
return st ? `${st}:${zwei(m)}:${zwei(r)}` : `${m}:${zwei(r)}`;
|
||
}
|
||
|
||
/* =================================================================
|
||
DAS PULT AUFBAUEN -- einmal, beim ersten Zeichnen
|
||
================================================================= */
|
||
function pultAufbauen() {
|
||
const pult = $('pult');
|
||
pult.dataset.auf = 'ja';
|
||
|
||
/* ---- Die Register ----
|
||
Sie stehen seit dem 28.09. oben über dem Saal und nicht mehr
|
||
im einklappbaren Teil. Verdrahtet werden sie trotzdem hier:
|
||
Sie gehören zur Regie, und wer kein Pult hat, hat auch keine
|
||
Register. */
|
||
for (const knopf of reiterKnoepfe()) {
|
||
knopf.addEventListener('click', () => reiterWechseln(knopf.dataset.tafel));
|
||
}
|
||
reiterTastatur();
|
||
/* DIE MARKE BRAUCHT EINE GEMESSENE BREITE. Beim ersten Zeichnen
|
||
ist die Hausschrift oft noch nicht da; ein Knopf ist dann
|
||
schmaler als gleich darauf. Also einmal jetzt und einmal,
|
||
sobald die Schriften stehen. */
|
||
reiterMarke();
|
||
if (document.fonts && document.fonts.ready) {
|
||
document.fonts.ready.then(() => reiterMarke()).catch(() => { });
|
||
}
|
||
window.addEventListener('resize', reiterMarke);
|
||
|
||
/* ---- Die Regler ---- */
|
||
const reihe = $('regler');
|
||
reihe.textContent = '';
|
||
for (const r of REGLER) {
|
||
const kasten = el('div', 'regler' + (r.schalter ? ' regler--schalter' : ''));
|
||
kasten.dataset.was = r.schluessel;
|
||
kasten.append(el('span', 'regler__wort', r.wort));
|
||
|
||
if (!r.schalter) {
|
||
const schieber = document.createElement('input');
|
||
schieber.type = 'range';
|
||
schieber.min = '0'; schieber.max = '100'; schieber.value = '100';
|
||
schieber.setAttribute('aria-label', r.wort);
|
||
const zahl = el('span', 'regler__zahl', '100');
|
||
schieber.addEventListener('input', () => {
|
||
staende[r.schluessel] = Number(schieber.value);
|
||
zahl.textContent = schieber.value;
|
||
tonNachziehen();
|
||
});
|
||
kasten.append(schieber);
|
||
const balken = el('div', 'pegel');
|
||
balken.append(el('i'));
|
||
kasten.append(balken);
|
||
kasten.append(zahl);
|
||
} else {
|
||
const balken = el('div', 'pegel');
|
||
balken.append(el('i'));
|
||
kasten.append(balken);
|
||
}
|
||
|
||
const stumm = el('button', 'regler__stumm', r.schalter ? 'aus' : 'stumm');
|
||
stumm.type = 'button';
|
||
stumm.addEventListener('click', () => stummSchalten(r.schluessel));
|
||
kasten.append(stumm);
|
||
reihe.append(kasten);
|
||
}
|
||
$('ton-hinweis').textContent =
|
||
'Die Regler gelten für das, was DU hörst. Nur „Mein Mikro" und der '
|
||
+ 'Stummschalter an einer Gastkarte gelten für alle. Der Balken zeigt '
|
||
+ 'den gemessenen Pegel — beim YouTube-Regler die eingestellte '
|
||
+ 'Lautstärke, denn Ton aus einem fremden Rahmen lässt sich nicht messen.';
|
||
|
||
/* ---- Anordnung ----
|
||
Die ersten drei sind Größenverhältnisse, die letzten zwei ein
|
||
Weglassen. Filipe: „nur mein bild, nur das video, möglichkeit
|
||
zwischen allem zu wechseln so wie ich will." */
|
||
const layouts = [
|
||
['kino', 'Video groß'],
|
||
['gleich', 'Gleich groß'],
|
||
['kamera', 'Kameras groß'],
|
||
['nur_video', 'Nur Video'],
|
||
['nur_kamera', 'Nur Kamera'],
|
||
];
|
||
werbungPultBauen();
|
||
|
||
const lreihe = $('layout-reihe');
|
||
lreihe.textContent = '';
|
||
for (const [schluessel, wort] of layouts) {
|
||
const k = el('button', 'layout-knopf', wort);
|
||
k.type = 'button';
|
||
k.dataset.layout = schluessel;
|
||
k.addEventListener('click', () => layoutSetzen(schluessel));
|
||
lreihe.append(k);
|
||
}
|
||
|
||
/* ---- Größe der Kamerafenster ----
|
||
Zwischen „Video groß" (208 px) und „Kameras groß" (380 px) gab
|
||
es nichts. Für eine Sendung, in der zwei Leute reden, ist das
|
||
eine zu klein und das andere verdeckt das Video.
|
||
|
||
Die Werte kommen vom Server (`lage.kamera_groessen`). Eine
|
||
zweite Liste hier wäre eine, die irgendwann von der ersten
|
||
abweicht -- und dann steht ein Knopf da, den der Server
|
||
ablehnt. */
|
||
const greihe = $('kamgroesse');
|
||
greihe.textContent = '';
|
||
for (const wert of (lage.kamera_groessen || [1])) {
|
||
const k = el('button', 'kamgroesse__knopf',
|
||
wert === 1 ? '1×' : `${String(wert).replace('.', ',')}×`);
|
||
k.type = 'button';
|
||
k.dataset.gross = String(wert);
|
||
k.title = wert === 1 ? 'Normale Größe'
|
||
: `${String(wert).replace('.', ',')}-fache Größe`;
|
||
k.addEventListener('click', () => bildSetzen({ kamera_gross: wert }));
|
||
greihe.append(k);
|
||
}
|
||
|
||
/* ---- Ecke ----
|
||
Vier Knöpfe mit einem kleinen Feld darin, das die Ecke zeigt.
|
||
Ein Wort wie „oben links" wäre länger zu lesen, als das Bild
|
||
zu erfassen ist -- und vier Wörter nebeneinander passen am
|
||
Handy nicht in eine Zeile. Der Name steht trotzdem da, als
|
||
`aria-label` und als Hinweis unter dem Zeiger. */
|
||
const ECKENWORT = {
|
||
ol: 'Oben links', or: 'Oben rechts',
|
||
ul: 'Unten links', ur: 'Unten rechts',
|
||
};
|
||
const ereihe = $('kamecke');
|
||
ereihe.textContent = '';
|
||
for (const ecke of (lage.kamera_ecken || ['ur'])) {
|
||
const k = el('button', 'kamecke__knopf');
|
||
k.type = 'button';
|
||
k.dataset.ecke = ecke;
|
||
k.title = ECKENWORT[ecke] || ecke;
|
||
k.setAttribute('aria-label', ECKENWORT[ecke] || ecke);
|
||
k.append(el('i', 'kamecke__feld'));
|
||
k.addEventListener('click', () => bildSetzen({ kamera_ecke: ecke }));
|
||
ereihe.append(k);
|
||
}
|
||
|
||
/* ---- Geschwindigkeit ----
|
||
Die möglichen Werte kommen vom Server (`lage.tempi`) und
|
||
stehen nicht hier. Eine zweite Liste an einer zweiten Stelle
|
||
ist eine Liste, die irgendwann auseinanderläuft -- und dann
|
||
steht hier ein Knopf, den der Server ablehnt. */
|
||
const treihe = $('tempo');
|
||
treihe.textContent = '';
|
||
for (const wert of (lage.tempi || [0.5, 0.75, 1, 1.25, 1.5, 2])) {
|
||
const k = el('button', 'tempo__knopf', wert === 1 ? '1×' : `${String(wert).replace('.', ',')}×`);
|
||
k.type = 'button';
|
||
k.dataset.tempo = String(wert);
|
||
k.title = wert === 1 ? 'Normal' : `${String(wert).replace('.', ',')}-fache Geschwindigkeit`;
|
||
k.addEventListener('click', () => tempoSetzen(wert));
|
||
treihe.append(k);
|
||
}
|
||
|
||
/* ---- Die Tempo-Gruppe klappt am Fingergerät ----
|
||
Der Zustand liegt im Browser und nicht an der Sendung: Ob
|
||
ICH die sechs Stufen sehen will, geht die anderen nichts an
|
||
-- anders als das Tempo selbst, das für alle gilt. */
|
||
$('tempo-auf').addEventListener('click', () => {
|
||
const teil = $('tempo-teil');
|
||
const auf = teil.dataset.auf !== 'auf';
|
||
teil.dataset.auf = auf ? 'auf' : 'zu';
|
||
$('tempo-auf').setAttribute('aria-expanded', String(auf));
|
||
try { localStorage.setItem('reaktion-tempo-auf', auf ? 'auf' : 'zu'); } catch { /* egal */ }
|
||
});
|
||
try {
|
||
if (localStorage.getItem('reaktion-tempo-auf') === 'auf') {
|
||
$('tempo-teil').dataset.auf = 'auf';
|
||
$('tempo-auf').setAttribute('aria-expanded', 'true');
|
||
}
|
||
} catch { /* ohne Gedächtnis bleibt sie zu -- das ist die sichere Seite */ }
|
||
|
||
/* ---- Das zweite Video ---- */
|
||
$('p-zweit-sichern').addEventListener('click', zweitSichern);
|
||
$('p-zweit').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); zweitSichern(); }
|
||
});
|
||
$('v-tauschen').addEventListener('click', tauschen);
|
||
|
||
/* ---- Die Videospur ---- */
|
||
/* ---- Bühne ---- */
|
||
$('obs-neu').addEventListener('click', buehneNeu);
|
||
buehneHolen();
|
||
|
||
/* ---- Gestaltung ---- */
|
||
$('stufe-neu').addEventListener('click', stufeDazu);
|
||
$('stufen-sichern').addEventListener('click', stufenSichern);
|
||
$('zeichen-datei').addEventListener('change', (e) => zeichenHoch(e.target));
|
||
$('zeichen-weg').addEventListener('click', zeichenWeg);
|
||
|
||
/* ---- Spenden ---- */
|
||
$('p-zuruecksetzen').addEventListener('click', allesZuruecksetzen);
|
||
$('sp-zeigen').addEventListener('click', spendeEintragen);
|
||
$('sp-betrag').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); spendeEintragen(); }
|
||
});
|
||
/* Beim Tippen mitrechnen: Die Zahl von der Zahlungsmeldung steht
|
||
in Euro, eingetragen wird in Dogen -- ohne die Umrechnung
|
||
daneben ist jeder Eintrag geraten. */
|
||
$('sp-betrag').addEventListener('input', () => {
|
||
$('sp-preis').textContent = alsPreis(dogenInMille($('sp-betrag').value));
|
||
});
|
||
|
||
$('v-spiel').addEventListener('click', spielPause);
|
||
$('v-zurueck').addEventListener('click', () => springen(-10));
|
||
$('v-vor').addEventListener('click', () => springen(10));
|
||
$('v-naechstes').addEventListener('click', () => weiterSchalten(0));
|
||
const band = $('v-band');
|
||
band.addEventListener('pointerdown', () => { bandZieht = true; });
|
||
band.addEventListener('input', () => {
|
||
bandZieht = true;
|
||
if (!spielerBereit) return;
|
||
const dauer = spieler.getDuration() || 0;
|
||
$('v-zeit').textContent = `${alsZeit(dauer * band.value / 1000)} / ${alsZeit(dauer)}`;
|
||
band.style.setProperty('--fortschritt', (band.value / 10) + '%');
|
||
});
|
||
band.addEventListener('change', () => {
|
||
bandZieht = false;
|
||
if (!spielerBereit) return;
|
||
const dauer = spieler.getDuration() || 0;
|
||
spieler.seekTo(dauer * band.value / 1000, true);
|
||
hostMelden();
|
||
});
|
||
|
||
/* ---- Die Warteschlange ---- */
|
||
$('l-anhaengen').addEventListener('click', anhaengen);
|
||
$('l-neu').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); anhaengen(); }
|
||
});
|
||
|
||
/* ---- Die grossen Knoepfe ---- */
|
||
$('p-sichern').addEventListener('click', einstellen);
|
||
$('p-vorbereiten').addEventListener('click', () => standSetzen('vorbereitung'));
|
||
$('p-starten').addEventListener('click', () => standSetzen('live'));
|
||
$('p-beenden').addEventListener('click', async () => {
|
||
const ja = window.frageNach
|
||
? await window.frageNach({
|
||
titel: 'Sendung beenden?',
|
||
/* SEIT DEM 30.09. WIRD AUCH DER SCHREIBTISCH GELEERT --
|
||
Titel, Video, Vorschaubild, Beginn und das zweite Video.
|
||
Das gehört in diesen Satz: Wer es nicht weiß, trägt
|
||
danach alles noch einmal ein und hält es für einen
|
||
Fehler. */
|
||
was: 'Der Saal geht zu, der Live-Chat dieser Sendung wird abgeräumt, '
|
||
+ 'die Gäste werden entlassen — und Titel, Video, Vorschaubild, '
|
||
+ 'Beginn und das zweite Video werden geleert.',
|
||
bleibt: 'Die Sendung steht danach im Verlauf. Spenden, Dogen und '
|
||
+ 'die Warteschlange bleiben unberührt.',
|
||
ja: 'Beenden', nein: 'Weiterlaufen lassen',
|
||
})
|
||
: false;
|
||
if (ja) standSetzen('zu');
|
||
});
|
||
$('p-holen').addEventListener('click', gastHolen);
|
||
|
||
$('pult-griff').addEventListener('click', () => {
|
||
const auf = pult.dataset.auf !== 'ja';
|
||
pult.dataset.auf = auf ? 'ja' : 'nein';
|
||
$('pult-griff').setAttribute('aria-expanded', String(auf));
|
||
});
|
||
|
||
tastaturAnhaengen();
|
||
|
||
/* Zwei Takte, die nur das Pult braucht. Die Pegel laufen
|
||
woanders -- sie gehoeren jedem, der sendet. */
|
||
setInterval(leisteTakt, 500); // Uhr und Positionsband
|
||
setInterval(lastMessen, 2500); // was die eigene Leitung traegt
|
||
}
|
||
|
||
/* =================================================================
|
||
DIE REGIELEISTE
|
||
|
||
Filipe, 28.09.2026: „ich hätte das gerne oben also nicht in
|
||
dieser kachel sondern über der kachel wo die videos laufen."
|
||
|
||
Sie steht damit über dem Saal und ist der einzige Teil der
|
||
Regie, der auch bei zugeklapptem Pult sichtbar bleibt.
|
||
================================================================= */
|
||
|
||
/** Die Register -- ohne die Marke, die mit im Behälter liegt. */
|
||
function reiterKnoepfe() {
|
||
return [...$('reiter').querySelectorAll('.reiter__knopf')];
|
||
}
|
||
|
||
function reiterWechseln(name, vomMenschen = true) {
|
||
const knoepfe = reiterKnoepfe();
|
||
for (const knopf of knoepfe) {
|
||
const gewaehlt = knopf.dataset.tafel === name;
|
||
knopf.setAttribute('aria-selected', String(gewaehlt));
|
||
/* WANDERNDER TABULATOR. In einer Registerleiste liegt genau
|
||
EIN Register in der Tabulatorfolge; zwischen ihnen wandert
|
||
man mit den Pfeiltasten. Sieben Tabulatorschritte durch
|
||
Register, bevor man an den Inhalt kommt, sind das Gegenteil
|
||
von bedienbar. */
|
||
knopf.tabIndex = gewaehlt ? 0 : -1;
|
||
}
|
||
for (const tafel of document.querySelectorAll('.pult-tafel')) {
|
||
tafel.hidden = tafel.dataset.tafel !== name;
|
||
}
|
||
|
||
/* EIN REGISTER ANZUTIPPEN MACHT DIE REGIE AUF.
|
||
Seit die Leiste oben steht, kann das Pult zugeklappt sein,
|
||
während man ein Register wählt -- der Reiter wechselte dann,
|
||
und auf dem Bildschirm passierte nichts. Genau die Sorte
|
||
Knopf, die man für kaputt hält.
|
||
|
||
NUR AUF, NIE ZU: Zugemacht wird mit dem Griff am Pult. Ein
|
||
Knopf, der beim zweiten Druck das Gegenteil tut, versteckt
|
||
irgendwann etwas, das jemand gerade lesen wollte. */
|
||
if (vomMenschen) {
|
||
const pult = $('pult');
|
||
if (pult && pult.dataset.auf !== 'ja') {
|
||
pult.dataset.auf = 'ja';
|
||
$('pult-griff')?.setAttribute('aria-expanded', 'true');
|
||
}
|
||
}
|
||
|
||
reiterMarke();
|
||
/* Das gewählte Register in den sichtbaren Bereich rollen -- auf
|
||
dem Handy liegen von sieben Registern nur drei im Bild. */
|
||
const aktiv = knoepfe.find((k) => k.dataset.tafel === name);
|
||
aktiv?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
|
||
}
|
||
|
||
/** Die Marke unter das gewählte Register schieben.
|
||
*
|
||
* GEMESSEN UND NICHT GERECHNET: `offsetLeft` und `offsetWidth`
|
||
* gegen den Behälter, der `position: relative` trägt. Damit ist
|
||
* es gleichgültig, wie breit ein Wort ist, wie weit die Leiste
|
||
* gerollt wurde oder welche Schrift gerade geladen ist.
|
||
*
|
||
* Und wenn hier etwas schiefgeht, bleibt die gefüllte Pille als
|
||
* zweite, rechnungsfreie Anzeige stehen. */
|
||
function reiterMarke() {
|
||
const marke = $('reiter-marke');
|
||
if (!marke) return;
|
||
const aktiv = reiterKnoepfe().find((k) => k.getAttribute('aria-selected') === 'true');
|
||
if (!aktiv || !aktiv.offsetWidth) { marke.hidden = true; return; }
|
||
marke.hidden = false;
|
||
/* Breite UND Höhe aus dem gemessenen Knopf. Die Höhe könnte man
|
||
auch über `top`/`bottom` erschlagen -- dann bezöge sie sich
|
||
aber auf die Polsterbox der Leiste und wäre elf Pixel zu
|
||
hoch. Zwei gemessene Zahlen sind ehrlicher als eine
|
||
Annahme über das Polster. */
|
||
marke.style.width = `${aktiv.offsetWidth}px`;
|
||
marke.style.height = `${aktiv.offsetHeight}px`;
|
||
marke.style.transform = `translate(${aktiv.offsetLeft}px, ${aktiv.offsetTop}px)`;
|
||
}
|
||
|
||
/** Was eine Registerleiste versprochen hat, muss sie auch tun.
|
||
*
|
||
* `role="tablist"` sagt Hilfsmitteln zu: Pfeiltasten wandern,
|
||
* Pos1 und Ende springen. Steht das Versprechen im Markup und
|
||
* löst es das Programm nicht ein, sagt der Vorleser etwas an, was
|
||
* nicht stimmt -- schlimmer als gar keine Rolle. */
|
||
function reiterTastatur() {
|
||
const leiste = $('reiter');
|
||
if (!leiste) return;
|
||
leiste.addEventListener('keydown', (e) => {
|
||
const knoepfe = reiterKnoepfe();
|
||
const jetzt = knoepfe.indexOf(document.activeElement);
|
||
if (jetzt < 0) return;
|
||
let ziel = -1;
|
||
if (e.key === 'ArrowRight') ziel = (jetzt + 1) % knoepfe.length;
|
||
else if (e.key === 'ArrowLeft') ziel = (jetzt - 1 + knoepfe.length) % knoepfe.length;
|
||
else if (e.key === 'Home') ziel = 0;
|
||
else if (e.key === 'End') ziel = knoepfe.length - 1;
|
||
if (ziel < 0) return;
|
||
e.preventDefault();
|
||
reiterWechseln(knoepfe[ziel].dataset.tafel);
|
||
knoepfe[ziel].focus();
|
||
});
|
||
}
|
||
|
||
function stummSchalten(schluessel) {
|
||
/* DAS EIGENE MIKRO IST DERSELBE SCHALTER WIE IN DER SENDERLEISTE
|
||
-- ein Zustand, zwei Stellen, die ihn zeigen. Zwei getrennte
|
||
Zustaende waeren zwei, die auseinanderlaufen koennen. */
|
||
if (schluessel === 'mikro') {
|
||
sendenSchalten('mikro', !lage.ich.mikro_aus);
|
||
return;
|
||
}
|
||
const kasten = document.querySelector(`.regler[data-was="${schluessel}"]`);
|
||
if (!kasten) return;
|
||
const jetztStumm = kasten.dataset.stumm !== 'ja';
|
||
kasten.dataset.stumm = jetztStumm ? 'ja' : 'nein';
|
||
tonNachziehen();
|
||
}
|
||
|
||
/** Anordnung, Größe und Ecke gehen denselben Weg.
|
||
*
|
||
* EIN WEG FÜR DAS GANZE BILD: Drei getrennte Aufrufe wären drei
|
||
* Rundrufe, und zwischen ihnen sähen die Zusehenden eine
|
||
* Mischung -- neue Anordnung, alte Ecke.
|
||
*
|
||
* UND DER EIGENE STAND WIRD GLEICH NACHGEZOGEN. Vorher verließ
|
||
* sich `layoutSetzen` allein auf den Rundruf, der auf dem Weg
|
||
* über den Server zurückkommt. Das ging gut, solange nur ein
|
||
* Wert im Spiel war; bei dreien sieht man in der Zwischenzeit
|
||
* die eigene Änderung noch nicht und drückt ein zweites Mal. */
|
||
async function bildSetzen(was) {
|
||
const a = await hole('/workspace/api/reaktion/layout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(was),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
const d = await a.json();
|
||
lage = {
|
||
...lage,
|
||
layout: d.layout,
|
||
kamera_gross: d.kamera_gross,
|
||
kamera_ecke: d.kamera_ecke,
|
||
};
|
||
zeichnen();
|
||
}
|
||
|
||
function layoutSetzen(schluessel) {
|
||
return bildSetzen({ layout: schluessel });
|
||
}
|
||
|
||
/* ---- Die Videospur bedienen -------------------------------------- */
|
||
function spielPause() {
|
||
if (!spielerBereit) return;
|
||
/* PUFFERN ZAEHLT ALS LAEUFT (berichtigt 30.09.2026).
|
||
|
||
Hier stand `=== 1`. Waehrend nachgeladen wird, ist der Zustand
|
||
3 -- also nicht 1, also ging der Klick in den else-Zweig und
|
||
rief `playVideo()`. Wer die Pause drueckte, bekam „weiter".
|
||
Der Knopf tat das Gegenteil dessen, was auf ihm stand, und
|
||
zwar genau dann, wenn die Leitung ohnehin schon stockt.
|
||
|
||
Dieselbe Verwechslung wie am 29.09. in `hostMelden()`. Die
|
||
Reparatur blieb damals an dieser einen Stelle haengen. */
|
||
const zustand = spieler.getPlayerState();
|
||
if (zustand === SPIELT || zustand === PUFFERT) spieler.pauseVideo();
|
||
else spieler.playVideo();
|
||
hostMelden();
|
||
}
|
||
function springen(um) {
|
||
if (!spielerBereit) return;
|
||
spieler.seekTo(Math.max(0, spieler.getCurrentTime() + um), true);
|
||
hostMelden();
|
||
}
|
||
async function weiterSchalten(id) {
|
||
const a = await hole('/workspace/api/reaktion/weiter', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: id || 0 }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
}
|
||
|
||
async function anhaengen() {
|
||
const feld = $('l-neu');
|
||
const roh = feld.value.trim();
|
||
if (!roh) { sagFehler('Erst eine YouTube-Adresse einfügen.'); return; }
|
||
$('l-anhaengen').disabled = true;
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/liste', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video: roh }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
feld.value = '';
|
||
zeichnen();
|
||
} catch { sagFehler('Das ging gerade nicht.'); }
|
||
finally { $('l-anhaengen').disabled = false; }
|
||
}
|
||
|
||
async function listeRuf(pfad, einstellungen) {
|
||
const a = await hole(pfad, einstellungen);
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
}
|
||
|
||
/* ---- Die Tastatur -------------------------------------------------
|
||
EIN KUERZEL, DAS NIEMAND KENNT, IST KEINS -- deshalb steht die
|
||
Legende unter dem Pult und nicht in einer Hilfe.
|
||
|
||
UND: Wer in einem Feld tippt, tippt. Die Leertaste gehoert dann
|
||
dem Text und nicht dem Video. Ohne diese Abfrage waere das
|
||
Titelfeld unbenutzbar, und zwar auf eine Art, die man sich nicht
|
||
erklaeren kann. */
|
||
function tastaturAnhaengen() {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!lage || !lage.ich.host) return;
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
if (e.target && e.target.closest('input, textarea, select, [contenteditable="true"]')) return;
|
||
|
||
const taste = e.key;
|
||
if (taste === ' ' || taste === 'Spacebar') { e.preventDefault(); spielPause(); return; }
|
||
if (taste === 'ArrowLeft') { e.preventDefault(); springen(-10); return; }
|
||
if (taste === 'ArrowRight') { e.preventDefault(); springen(10); return; }
|
||
if (taste === 'm' || taste === 'M') { e.preventDefault(); stummSchalten('mikro'); return; }
|
||
if (taste === 'n' || taste === 'N') { e.preventDefault(); weiterSchalten(0); return; }
|
||
if (taste === 't' || taste === 'T') { e.preventDefault(); tauschen(); return; }
|
||
if (taste === '1') { e.preventDefault(); layoutSetzen('kino'); return; }
|
||
if (taste === '2') { e.preventDefault(); layoutSetzen('gleich'); return; }
|
||
if (taste === '3') { e.preventDefault(); layoutSetzen('kamera'); return; }
|
||
if (taste === '4') { e.preventDefault(); layoutSetzen('nur_video'); return; }
|
||
if (taste === '5') { e.preventDefault(); layoutSetzen('nur_kamera'); return; }
|
||
/* SCHNELLER UND LANGSAMER SCHIEBEN IN DER LISTE, sie rechnen
|
||
nicht. Eine Rechnung („mal 1,25") träfe Werte, die es nicht
|
||
gibt, und der Server lehnte sie ab. */
|
||
if (taste === '+' || taste === '-' || taste === '−') {
|
||
e.preventDefault();
|
||
const liste = lage.tempi || [];
|
||
const i = liste.indexOf(lage.tempo || 1);
|
||
if (i < 0) return;
|
||
const ziel = liste[taste === '+' ? Math.min(liste.length - 1, i + 1) : Math.max(0, i - 1)];
|
||
if (ziel !== lage.tempo) tempoSetzen(ziel);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Die Geschwindigkeit umstellen -- für alle.
|
||
*
|
||
* ERST DER EIGENE SPIELER, DANN DER SERVER. Der Knopf muss sofort
|
||
* wirken; die Meldung an die anderen darf eine Netzrunde dauern.
|
||
* Andersherum säße Filipe vor einem Knopf, der sich zwei
|
||
* Zehntelsekunden lang nicht entscheidet -- und drückt ihn ein
|
||
* zweites Mal. */
|
||
async function tempoSetzen(wert) {
|
||
tempoNachziehen(wert);
|
||
/* UEBER `hostMelden()` UND NICHT NOCH EINMAL VON HAND
|
||
(berichtigt 30.09.2026). Hier stand dieselbe Meldung ein
|
||
zweites Mal, und sie machte beides falsch, was `hostMelden()`
|
||
richtig macht: Sie hielt Puffern fuer Stillstand und liess das
|
||
Alter der Ablesung weg. Wer das Tempo umstellte, waehrend
|
||
seine Leitung nachlud, hielt damit alle Zuschauer an. */
|
||
const a = await hostMelden({ tempo: wert });
|
||
if (!a) { sagFehler('Das Tempo ging nicht.'); return; }
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das Tempo ging nicht.'); return; }
|
||
lage = { ...lage, tempo: wert };
|
||
zeichnen();
|
||
}
|
||
|
||
async function zweitSichern() {
|
||
const a = await hole('/workspace/api/reaktion/zweitvideo', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video: $('p-zweit').value.trim() }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
/* Der Name kommt von YouTube nach -- nach anderthalb Sekunden
|
||
steht er da, ohne dass jemand neu laden muss. */
|
||
setTimeout(standHolen, 1600);
|
||
}
|
||
|
||
async function tauschen() {
|
||
if (!lage || !lage.ich.host || !lage.zweit) return;
|
||
const a = await hole('/workspace/api/reaktion/tauschen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
/* Die Stelle kommt von hier und nicht aus der Datenbank: Dort
|
||
steht der Stand vom letzten Fünf-Sekunden-Takt. */
|
||
body: JSON.stringify({ sekunde: spielerBereit ? spieler.getCurrentTime() : 0 }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das Wechseln ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
}
|
||
|
||
/* ---- Der halbsekündliche Takt: Uhr und Positionsband -------------- */
|
||
function leisteTakt() {
|
||
if (!lage || !lage.ich.host) return;
|
||
|
||
/* Die Laufzeit */
|
||
const uhr = $('pult-uhr');
|
||
if (lage.stand === 'live' && lage.gestartet_am) {
|
||
const seit = Date.parse(lage.gestartet_am);
|
||
uhr.hidden = !Number.isFinite(seit);
|
||
if (!uhr.hidden) $('pult-zeit').textContent = alsZeit((Date.now() - seit) / 1000);
|
||
} else {
|
||
uhr.hidden = true;
|
||
}
|
||
|
||
/* Das Positionsband */
|
||
if (!spielerBereit) return;
|
||
/* UND AUCH HIER IST PUFFERN KEINE PAUSE. Ohne das sprang die
|
||
Lampe bei jedem Stocken der Leitung auf „Pause" und gleich
|
||
wieder zurueck -- ein Flackern, das sagt „etwas ist kaputt",
|
||
waehrend nur nachgeladen wird. Dasselbe galt fuer das Bild auf
|
||
dem Knopf: zwei Balken, Dreieck, zwei Balken. */
|
||
const zustandJetzt = spieler.getPlayerState();
|
||
const laeuft = zustandJetzt === SPIELT || zustandJetzt === PUFFERT;
|
||
$('spur').dataset.laeuft = laeuft ? 'ja' : 'nein';
|
||
$('spur-marke').textContent = laeuft ? 'Läuft' : 'Pause';
|
||
$('v-spiel-bild').innerHTML = laeuft
|
||
? '<path d="M8 5h3v14H8zm5 0h3v14h-3z" />'
|
||
: '<path d="M8 5l11 7-11 7z" />';
|
||
$('v-spiel').title = laeuft ? 'Pause (Leertaste)' : 'Abspielen (Leertaste)';
|
||
|
||
if (bandZieht) return;
|
||
const dauer = spieler.getDuration() || 0;
|
||
const jetzt = spieler.getCurrentTime() || 0;
|
||
$('v-zeit').textContent = `${alsZeit(jetzt)} / ${alsZeit(dauer)}`;
|
||
const band = $('v-band');
|
||
const anteil = dauer ? Math.min(1000, Math.round(jetzt / dauer * 1000)) : 0;
|
||
band.value = String(anteil);
|
||
band.style.setProperty('--fortschritt', (anteil / 10) + '%');
|
||
}
|
||
|
||
function tonNachziehen() {
|
||
/* YouTube */
|
||
if (spielerBereit) {
|
||
const aus = document.querySelector('.regler[data-was="video"]')?.dataset.stumm === 'ja';
|
||
spieler.setVolume(aus ? 0 : staende.video);
|
||
}
|
||
/* Die Gäste: das erste und zweite Kamerafenster, das nicht mir gehört. */
|
||
const fremde = [...document.querySelectorAll('#kameras .kamera')]
|
||
.filter((k) => k.dataset.wer !== String(meineId));
|
||
fremde.forEach((k, i) => {
|
||
const schluessel = i === 0 ? 'gast1' : 'gast2';
|
||
const kasten = document.querySelector(`.regler[data-was="${schluessel}"]`);
|
||
const aus = kasten && kasten.dataset.stumm === 'ja';
|
||
const v = k.querySelector('video');
|
||
if (v) v.volume = aus ? 0 : (staende[schluessel] / 100);
|
||
});
|
||
}
|
||
|
||
/* =================================================================
|
||
DAS PULT FUELLEN -- bei jeder Aenderung
|
||
================================================================= */
|
||
function pultFuellen() {
|
||
const pult = $('pult');
|
||
pult.dataset.stand = lage.stand;
|
||
|
||
/* Der Mikro-Schalter im Pult zeigt denselben Zustand wie der in
|
||
der Senderleiste -- er wird hier nur nachgefuehrt, nicht
|
||
zweitgefuehrt. */
|
||
const mikro = document.querySelector('.regler[data-was="mikro"]');
|
||
if (mikro) {
|
||
mikro.dataset.stumm = lage.ich.mikro_aus ? 'ja' : 'nein';
|
||
const k = mikro.querySelector('.regler__stumm');
|
||
if (k) k.textContent = lage.ich.mikro_aus ? 'an' : 'aus';
|
||
}
|
||
|
||
/* ---- Die Leiste ---- */
|
||
const worte = { zu: 'Geschlossen', vorbereitung: 'Gleich', live: 'Auf Sendung' };
|
||
$('pult-wort').textContent = worte[lage.stand] || '…';
|
||
$('z-dabei').textContent = String(lage.dabei);
|
||
const mitBild = (lage.zuschauer || []).filter((z) => z.sicht).length;
|
||
const sichtFeld = $('z-sicht');
|
||
sichtFeld.textContent = `${mitBild} / ${lage.plaetze}`;
|
||
sichtFeld.dataset.eng = mitBild >= lage.plaetze ? 'voll'
|
||
: (mitBild >= lage.plaetze - 2 ? 'ja' : 'nein');
|
||
$('z-gaeste').textContent = `${lage.gaeste.length} / ${lage.gaeste_max}`;
|
||
|
||
/* ---- Die Videospur ---- */
|
||
const titel = $('spur-titel');
|
||
if (lage.video) {
|
||
titel.textContent = lage.video_titel || lage.video;
|
||
titel.dataset.roh = lage.video_titel ? 'nein' : 'ja';
|
||
titel.title = lage.video_titel
|
||
? lage.video_titel
|
||
: 'YouTube hat den Namen nicht herausgerückt — hier steht die Kennung.';
|
||
} else {
|
||
titel.textContent = 'Noch kein Video eingetragen';
|
||
titel.dataset.roh = 'nein';
|
||
titel.title = '';
|
||
}
|
||
|
||
/* ==== DAS VORSCHAUBILD IN DER LINKEN KACHEL ================
|
||
|
||
Die erste Fassung zeigte dort nur einen Titel -- und wenn
|
||
keiner da war, stand ein grosses schwarzes Loch neben einer
|
||
vollen Spalte. Mit dem Bild ist die Kachel immer gefuellt.
|
||
|
||
EIGENES BILD SCHLAEGT YOUTUBE. Wer eines eintraegt, meint es.
|
||
Laedt es nicht, tritt YouTubes eigenes Vorschaubild ein -- und
|
||
ohne Video der Satz, der sagt, was zu tun ist. */
|
||
const kasten = $('laufbild');
|
||
const vorschau = $('spur-bild');
|
||
if (kasten && vorschau) {
|
||
const quelle = String(lage.vorschau || '').trim()
|
||
|| (lage.video ? 'https://i.ytimg.com/vi/' + lage.video + '/mqdefault.jpg' : '');
|
||
if (quelle) {
|
||
if (vorschau.getAttribute('src') !== quelle) vorschau.setAttribute('src', quelle);
|
||
kasten.dataset.bild = 'ja';
|
||
/* Ein Bild, das 404 antwortet, steht genauso im Dokument wie
|
||
eines, das laedt -- auf dem Bildschirm ist an seiner
|
||
Stelle nichts. Dann lieber der Platzhaltersatz. */
|
||
vorschau.onerror = () => { kasten.dataset.bild = 'nein'; };
|
||
} else {
|
||
vorschau.removeAttribute('src');
|
||
kasten.dataset.bild = 'nein';
|
||
}
|
||
$('laufbild-leer').textContent = lage.video
|
||
? 'Für dieses Video gibt es kein Vorschaubild.'
|
||
: 'Noch kein Video — trag rechts eine YouTube-Adresse ein.';
|
||
}
|
||
for (const id of ['v-spiel', 'v-zurueck', 'v-vor', 'v-band']) {
|
||
$(id).disabled = !lage.video || lage.stand !== 'live';
|
||
}
|
||
$('v-naechstes').disabled = !(lage.liste && lage.liste.length);
|
||
|
||
/* ---- Die Warteschlange ---- */
|
||
listeZeichnen();
|
||
|
||
/* ---- Die Felder ---- */
|
||
if (document.activeElement !== $('p-titel')) $('p-titel').value = lage.titel || '';
|
||
if (document.activeElement !== $('p-video')) $('p-video').value = lage.video || '';
|
||
if (document.activeElement !== $('p-vorschau')) $('p-vorschau').value = lage.vorschau || '';
|
||
/* ZWEI FRAGEN, ZWEI BEDINGUNGEN (30.09.2026).
|
||
|
||
Hier stand `if (lage.beginnt_am && document.activeElement !== …)`.
|
||
„Tippt gerade jemand darin?" ist die eine Frage, „gibt es einen
|
||
Wert?" die andere — zusammengezogen beantwortet die zweite die
|
||
erste stillschweigend mit nein: Fiel der Termin weg, wurde der
|
||
ganze Block übersprungen, und im Kasten stand das alte Datum
|
||
weiter, bis jemand die Seite neu lud.
|
||
|
||
Das betraf auch den Knopf „Alles zurücksetzen": Die Spalte war
|
||
leer, das Pult zeigte den Termin trotzdem. Titel und Video
|
||
haben es immer richtig gemacht (`|| ''`), dieses eine Feld
|
||
nicht. */
|
||
if (document.activeElement !== $('p-beginn')) {
|
||
const d = lage.beginnt_am ? new Date(lage.beginnt_am) : null;
|
||
if (d && !Number.isNaN(d.getTime())) {
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
$('p-beginn').value = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||
+ `T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
} else {
|
||
$('p-beginn').value = '';
|
||
}
|
||
}
|
||
|
||
/* Die Knöpfe sagen, was als Nächstes dran ist -- und was nicht
|
||
geht, ist ausgegraut statt unsichtbar. Ein Knopf, der
|
||
verschwindet, lässt einen suchen; einer, der grau ist,
|
||
erklärt sich. */
|
||
$('p-vorbereiten').disabled = lage.stand === 'vorbereitung';
|
||
$('p-starten').disabled = lage.stand === 'live' || !lage.video;
|
||
$('p-beenden').disabled = lage.stand === 'zu';
|
||
$('p-starten').textContent = lage.video ? 'Auf Sendung' : 'Erst ein Video eintragen';
|
||
|
||
/* ZURUECKSETZEN GEHT NICHT IM LIVE. Wer mitten in der Sendung
|
||
zuruecksetzt, zieht allen das Video unter den Fuessen weg --
|
||
und das ist nicht rueckgaengig zu machen. Der Knopf wird grau
|
||
und sagt, warum; einer, der verschwindet, laesst einen
|
||
suchen. */
|
||
const laeuftGerade = lage.stand === 'live';
|
||
$('p-zuruecksetzen').disabled = laeuftGerade;
|
||
$('zurueck-hinweis').textContent = laeuftGerade
|
||
? 'Geht erst, wenn die Sendung beendet ist.'
|
||
: 'Leert das Pult — Spenden, Dogen und Chat bleiben.';
|
||
|
||
/* ---- Die Werbung nachziehen ---- */
|
||
if (lage.ich?.host && lage.werbung) {
|
||
const w = lage.werbung;
|
||
for (const k of ($('bandart-reihe')?.children || [])) {
|
||
k.setAttribute('aria-pressed', String(k.dataset.art === (w.art || 'aus')));
|
||
}
|
||
for (const k of ($('bandtempo-reihe')?.children || [])) {
|
||
k.setAttribute('aria-pressed', String(Number(k.dataset.tempo) === (w.tempo || 16)));
|
||
}
|
||
for (const k of ($('eckengroesse-reihe')?.children || [])) {
|
||
k.setAttribute('aria-pressed', String(Number(k.dataset.gross) === (w.ecke_gross || 1)));
|
||
}
|
||
/* WO STEHEN GERADE DIE KAMERAS? (30.09.2026)
|
||
|
||
Offener Punkt vom 29.09.: „Unten rechts" liegt im Layout
|
||
„Kino" genau dort, wo die Kamerakacheln sitzen — man sieht
|
||
es erst, wenn das Logo darauf liegt.
|
||
|
||
GEZEIGT, NICHT VERBOTEN. Vielleicht soll das Logo genau
|
||
dorthin, weil die Kameras gleich umziehen oder weil auf
|
||
„nur Video" geschaltet wird. Wer entscheidet, braucht die
|
||
Auskunft — nicht die Entmündigung.
|
||
|
||
NUR IM LAYOUT „KINO". `kamera_ecke` steuert den Stapel nur
|
||
dort; bei „gleich" und „Kamera groß" stehen die Kameras
|
||
woanders, bei „nur Video" gar nicht. Ein Hinweis, der auch
|
||
dann erschiene, warnte vor etwas, das nicht passieren kann
|
||
— und so etwas gewöhnt man sich ab. */
|
||
const kameraEcke = (lage.layout === 'kino') ? (lage.kamera_ecke || '') : '';
|
||
for (const [welches, feld, wort] of [
|
||
['dogi', 'ecke_dogi', 'Der Husky'],
|
||
['hase', 'ecke_hase', 'Der Häkelhase'],
|
||
]) {
|
||
const gitter = $(`eckenwahl-${welches}`)?.querySelector('.eckenwahl__gitter');
|
||
for (const k of (gitter?.children || [])) {
|
||
k.setAttribute('aria-pressed', String(k.dataset.platz === (w[feld] || 'aus')));
|
||
/* Auch im Vorlesetext, nicht nur als Farbe — sonst gibt es
|
||
den Hinweis für den nicht, der die Seite vorlesen lässt
|
||
oder Farben schlecht unterscheidet. */
|
||
const dortKameras = !!kameraEcke && k.dataset.platz === kameraEcke;
|
||
k.dataset.kameras = dortKameras ? 'ja' : 'nein';
|
||
const platzWort = ECKEN_WORT[k.dataset.platz] || k.dataset.platz;
|
||
k.title = dortKameras
|
||
? `${platzWort} — hier stehen gerade die Kameras`
|
||
: platzWort;
|
||
k.setAttribute('aria-label', dortKameras
|
||
? `${wort}: ${platzWort} — Achtung, hier stehen gerade die Kameras`
|
||
: `${wort}: ${platzWort}`);
|
||
}
|
||
}
|
||
/* DAS TEMPO GILT NUR, WENN ETWAS LAEUFT. Ein Knopf, der
|
||
nichts bewirken kann, ist schlimmer als keiner -- man
|
||
drueckt ihn und sucht den Fehler bei sich. */
|
||
const tfeld = $('bandtempo-feld');
|
||
if (tfeld) tfeld.dataset.still = (w.art === 'aus') ? 'ja' : 'nein';
|
||
bandlisteZeichnen();
|
||
}
|
||
|
||
for (const k of $('layout-reihe').children) {
|
||
k.setAttribute('aria-pressed', String(k.dataset.layout === (lage.layout || 'kino')));
|
||
}
|
||
for (const k of $('tempo').children) {
|
||
k.setAttribute('aria-pressed', String(Number(k.dataset.tempo) === (lage.tempo || 1)));
|
||
}
|
||
/* DER KLAPPKNOPF TRAEGT DEN AKTUELLEN WERT. Zugeklappt ist er
|
||
die einzige Auskunft darüber, wie schnell es läuft -- ein
|
||
Knopf, der dazu schweigt, zwingt zum Aufklappen, nur um
|
||
nachzusehen. */
|
||
{
|
||
const t = lage.tempo || 1;
|
||
const k = $('tempo-auf');
|
||
if (k) k.textContent = t === 1 ? '1×' : `${String(t).replace('.', ',')}×`;
|
||
}
|
||
for (const k of $('kamgroesse').children) {
|
||
k.setAttribute('aria-pressed',
|
||
String(Number(k.dataset.gross) === (lage.kamera_gross || 1)));
|
||
}
|
||
/* ==== EIN KNOPF, DER NICHTS TUN KANN, SIEHT AUCH SO AUS ====
|
||
|
||
Die GRÖSSE wirkt überall, wo es Kamerafenster gibt — nur bei
|
||
„Nur Video" gibt es gar keine. Wer dort auf 2× drückt, sieht
|
||
nichts passieren und sucht den Fehler bei sich.
|
||
|
||
Die ECKE wirkt nur bei „Video groß"; in den anderen
|
||
Anordnungen stehen die Fenster mittig oder füllen die Fläche.
|
||
|
||
Beide werden nicht versteckt, sondern grau. Ein Knopf, der
|
||
verschwindet, lässt einen suchen; einer, der grau ist,
|
||
erklärt sich — derselbe Grundsatz wie bei den drei großen
|
||
Knöpfen im Pult. */
|
||
const anordnung = lage.layout || 'kino';
|
||
const kamerasDa = anordnung !== 'nur_video';
|
||
const eckeGilt = anordnung === 'kino';
|
||
for (const k of $('kamgroesse').children) k.disabled = !kamerasDa;
|
||
for (const k of $('kamecke').children) {
|
||
k.setAttribute('aria-pressed', String(k.dataset.ecke === (lage.kamera_ecke || 'ur')));
|
||
k.disabled = !eckeGilt;
|
||
}
|
||
/* EIN SATZ, NICHT DREI. Es gibt immer nur einen Grund
|
||
gleichzeitig, und drei Zeilen untereinander liest mitten in
|
||
einer Sendung niemand. */
|
||
$('kamecke-hinweis').textContent = !kamerasDa
|
||
? 'Bei „Nur Video" sind die Kamerafenster ganz weg — Größe und Ecke '
|
||
+ 'gelten wieder, sobald sie zurückkommen.'
|
||
: eckeGilt
|
||
? 'Unten rechts liegt bei TikTok die Knopfreihe — such dir eine freie Ecke.'
|
||
: 'Die Ecke gilt bei „Video groß" — gerade stehen die Fenster in der Mitte.';
|
||
|
||
/* ---- Der Umschalter ----
|
||
Er steht nur da, wenn es etwas zum Umschalten GIBT. Ein Knopf,
|
||
der „kein_zweites" antwortet, ist eine Falle -- und im Live
|
||
eine, die man erst merkt, wenn man ihn braucht. */
|
||
const tausch = $('v-tauschen');
|
||
tausch.hidden = !lage.zweit;
|
||
if (lage.zweit) {
|
||
const wort = lage.zweit.titel || lage.zweit.video;
|
||
$('v-tausch-titel').textContent = wort.length > 34 ? wort.slice(0, 33) + '…' : wort;
|
||
tausch.title = `Wechseln zu „${wort}"`
|
||
+ (lage.zweit.sekunde > 2 ? ` — weiter bei ${alsZeit(lage.zweit.sekunde)}` : '');
|
||
}
|
||
if (document.activeElement !== $('p-zweit')) {
|
||
$('p-zweit').value = lage.zweit ? lage.zweit.video : '';
|
||
}
|
||
|
||
/* ---- Gäste ---- */
|
||
$('gast-zahl').textContent = `${lage.gaeste.length} von ${lage.gaeste_max}`;
|
||
const greihe = $('gast-reihe');
|
||
greihe.textContent = '';
|
||
if (!lage.gaeste.length) {
|
||
greihe.append(el('p', 'gast-leer', 'Noch niemand dabei.'));
|
||
}
|
||
for (const g of lage.gaeste) {
|
||
const karte = el('div', 'gast-karte');
|
||
karte.append(el('span', 'gast-karte__platz', 'G' + g.platz));
|
||
karte.append(el('span', null, g.name));
|
||
const stumm = el('button', null, g.stumm ? 'laut' : 'stumm');
|
||
stumm.type = 'button';
|
||
stumm.title = g.stumm ? 'Wieder hörbar machen' : 'Für alle stummschalten';
|
||
stumm.addEventListener('click', async () => {
|
||
const a = await hole(`/workspace/api/reaktion/gast/${g.id}/stumm`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ stumm: !g.stumm }),
|
||
});
|
||
if (!a.ok) melde(await a.json().catch(() => null), 'Das ging nicht.');
|
||
});
|
||
const weg = el('button', null, '×');
|
||
weg.type = 'button';
|
||
weg.title = 'Aus der Sendung nehmen';
|
||
weg.addEventListener('click', async () => {
|
||
const a = await hole('/workspace/api/reaktion/gast/' + g.id, { method: 'DELETE' });
|
||
if (!a.ok) melde(await a.json().catch(() => null), 'Das ging nicht.');
|
||
});
|
||
karte.append(stumm);
|
||
karte.append(weg);
|
||
greihe.append(karte);
|
||
}
|
||
|
||
/* Wen kann ich holen? Nur wer gerade zusieht -- jemanden zu
|
||
holen, der gar nicht da ist, ergibt eine Kachel ohne Bild. */
|
||
const wahl = $('p-wen');
|
||
const vorher = wahl.value;
|
||
wahl.textContent = '';
|
||
const leer = el('option', null, 'Wer gerade dabei ist …');
|
||
leer.value = '';
|
||
wahl.append(leer);
|
||
for (const z of (lage.zuschauer || [])) {
|
||
if (z.id === meineId) continue;
|
||
if (lage.gaeste.some((g) => g.id === z.id)) continue;
|
||
const o = el('option', null, z.name);
|
||
o.value = String(z.id);
|
||
wahl.append(o);
|
||
}
|
||
if (vorher) wahl.value = vorher;
|
||
}
|
||
|
||
/* ---- Die Bühnenquellen --------------------------------------------
|
||
Drei Adressen zum Kopieren. Der Schlüssel darin ist ein
|
||
Geheimnis: Wer ihn hat, sieht das laufende Video und die
|
||
Spendenkarten -- also genau das, was ohnehin im Stream steht,
|
||
aber eben ohne Anmeldung.
|
||
|
||
DESHALB STEHT ER NIE AUSGESCHRIEBEN DA. Die Zeile zeigt die
|
||
Adresse gekürzt; der Knopf legt sie vollständig in die
|
||
Zwischenablage. Ein Schlüssel, der offen auf dem Bildschirm
|
||
steht, ist einen Screenshot davon entfernt, keiner mehr zu
|
||
sein -- und dieser Bildschirm wird gestreamt. */
|
||
let buehneAdressen = null;
|
||
|
||
async function buehneHolen() {
|
||
try {
|
||
const a = await hole('/workspace/api/buehne/adressen');
|
||
if (!a.ok) return;
|
||
buehneAdressen = await a.json();
|
||
buehneZeichnen();
|
||
} catch { /* ohne Adressen ist die Regie vollständig */ }
|
||
}
|
||
|
||
function buehneZeichnen() {
|
||
const kasten = $('quellen');
|
||
if (!kasten || !buehneAdressen) return;
|
||
$('obs-quellen').textContent = buehneAdressen.quellen === 1
|
||
? '1 Quelle verbunden' : `${buehneAdressen.quellen} Quellen verbunden`;
|
||
|
||
const zeilen = [
|
||
['Video', 'Das laufende Video als Browser-Quelle', buehneAdressen.video],
|
||
['Spenden', 'Nur die Karten, durchsichtiger Grund', buehneAdressen.tafel],
|
||
['Mit Gästen', 'Diese Seite ohne Bedienung — als Fenster aufnehmen',
|
||
buehneAdressen.buehnenmodus],
|
||
];
|
||
kasten.textContent = '';
|
||
for (const [name, was, adresse] of zeilen) {
|
||
const zeile = el('div', 'quelle');
|
||
const links = el('div', 'quelle__text');
|
||
links.append(el('span', 'quelle__name', name));
|
||
links.append(el('span', 'quelle__was', was));
|
||
zeile.append(links);
|
||
|
||
const knopf = el('button', 'quelle__kopie', 'Kopieren');
|
||
knopf.type = 'button';
|
||
knopf.addEventListener('click', async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(adresse);
|
||
knopf.textContent = 'Kopiert';
|
||
knopf.dataset.fertig = 'ja';
|
||
setTimeout(() => { knopf.textContent = 'Kopieren'; knopf.dataset.fertig = 'nein'; }, 2200);
|
||
} catch {
|
||
/* Kein Zugriff auf die Zwischenablage -- dann die Adresse
|
||
zeigen, damit sie sich wenigstens markieren lässt. Ein
|
||
Knopf, der nichts tut und nichts sagt, ist das
|
||
Schlimmste von beidem. */
|
||
const feld = document.createElement('input');
|
||
feld.className = 'quelle__feld';
|
||
feld.value = adresse;
|
||
feld.readOnly = true;
|
||
zeile.append(feld);
|
||
feld.select();
|
||
knopf.hidden = true;
|
||
}
|
||
});
|
||
zeile.append(knopf);
|
||
kasten.append(zeile);
|
||
}
|
||
}
|
||
|
||
async function buehneNeu() {
|
||
const ja = window.frageNach
|
||
? await window.frageNach({
|
||
titel: 'Schlüssel neu erzeugen?',
|
||
was: 'Die alten Adressen hören sofort auf zu arbeiten — auch mitten '
|
||
+ 'in einer laufenden Sendung.',
|
||
bleibt: 'Du musst die Quellen in OBS einmal neu eintragen. Die '
|
||
+ 'Adressen stehen danach hier.',
|
||
endgueltig: true,
|
||
ja: 'Neu erzeugen', nein: 'Doch nicht',
|
||
})
|
||
: false;
|
||
if (!ja) return;
|
||
const a = await hole('/workspace/api/buehne/schluessel', { method: 'POST' });
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
const d = await a.json();
|
||
buehneAdressen = { ...buehneAdressen, ...d };
|
||
buehneZeichnen();
|
||
sagFehler('Neuer Schlüssel. Trag die Quellen in OBS neu ein.');
|
||
}
|
||
|
||
/* ---- Die Spenden im Pult ------------------------------------------
|
||
Drei Dinge: von Hand eintragen (geht immer), die offenen
|
||
Meldungen bestaetigen, und eine Karte noch einmal zeigen. */
|
||
function spendenPultZeichnen() {
|
||
if (!spendenLage || !spendenLage.ich?.darf) return;
|
||
const offen = spendenLage.offen || [];
|
||
const zahl = $('reiter-spenden-zahl');
|
||
if (zahl) {
|
||
zahl.textContent = String(offen.length);
|
||
zahl.dataset.leer = offen.length ? 'nein' : 'ja';
|
||
/* DIE ZAHL AENDERT DIE BREITE DES REGISTERS. Aus „0" wird
|
||
„12" -- der Knopf wird breiter, und die Marke darunter
|
||
stünde falsch, wenn sie nicht nachgezogen wird. */
|
||
reiterMarke();
|
||
}
|
||
$('sp-offen-zahl').textContent = offen.length === 1 ? '1 Meldung' : `${offen.length} Meldungen`;
|
||
|
||
const kasten = $('sp-offen');
|
||
kasten.textContent = '';
|
||
if (!offen.length) {
|
||
kasten.append(el('li', 'spendenliste__leer',
|
||
'Nichts offen. Wer über die Knöpfe spendet und es meldet, landet hier.'));
|
||
}
|
||
for (const k of offen) {
|
||
const zeile = el('li', 'spendenzeile');
|
||
const betrag = el('strong', 'spendenzeile__betrag');
|
||
betrag.append(window.Spendenkarte.dogenStueck(k.dogen_mille, k.zeichen));
|
||
/* Der Preis daneben -- das ist die Zahl, die in Filipes
|
||
Zahlungsmeldung steht. Ohne sie muesste er beim Bestaetigen
|
||
im Kopf umrechnen, und das mitten in einer Sendung. */
|
||
const preis = alsPreis(k.dogen_mille);
|
||
if (preis) betrag.append(el('span', 'preis preis--unter', preis));
|
||
zeile.append(betrag);
|
||
|
||
/* ==== DIE SCHRANKE VOR DEM STREAM =========================
|
||
|
||
Der Text kommt jetzt von dem, der gibt (Filipe: „es sollen
|
||
personalisierte texte sein von den leuten selbst") — und
|
||
damit steht zum ersten Mal ein fremder Satz kurz davor, im
|
||
Livestream zu erscheinen.
|
||
|
||
Er läuft ohnehin durch diese Bestätigung. Neu ist, dass er
|
||
sich HIER ÄNDERN lässt: Ohne das bliebe nur „ganz
|
||
ablehnen", und dann fiele wegen eines einzigen Wortes eine
|
||
echte Spende unter den Tisch. Ein Feld ist die mildere und
|
||
die schnellere Antwort. */
|
||
const mitte = el('div', 'spendenzeile__mitte');
|
||
const wer = document.createElement('input');
|
||
wer.type = 'text';
|
||
wer.className = 'spendenzeile__feld';
|
||
wer.maxLength = spendenLage.name_max || 40;
|
||
wer.value = k.name || '';
|
||
wer.setAttribute('aria-label', 'Name auf der Karte');
|
||
mitte.append(wer);
|
||
|
||
const text = document.createElement('input');
|
||
text.type = 'text';
|
||
text.className = 'spendenzeile__feld spendenzeile__feld--gruss';
|
||
text.maxLength = spendenLage.gruss_max || 140;
|
||
text.value = k.gruss || '';
|
||
text.placeholder = 'Kein Text dabei';
|
||
text.setAttribute('aria-label', 'Text auf der Karte');
|
||
mitte.append(text);
|
||
zeile.append(mitte);
|
||
|
||
const ja = el('button', 'spendenzeile__ja', 'Stimmt – zeigen');
|
||
ja.type = 'button';
|
||
ja.addEventListener('click', () => spendeEntscheiden(k.id, true, {
|
||
name: wer.value.trim(), gruss: text.value.trim(),
|
||
}));
|
||
const nein = el('button', 'spendenzeile__nein', 'Stimmt nicht');
|
||
nein.type = 'button';
|
||
nein.addEventListener('click', () => spendeEntscheiden(k.id, false));
|
||
zeile.append(ja); zeile.append(nein);
|
||
kasten.append(zeile);
|
||
}
|
||
|
||
const letzte = $('sp-letzte');
|
||
letzte.textContent = '';
|
||
for (const k of (spendenLage.letzte || [])) {
|
||
const zeile = el('li', 'spendenzeile');
|
||
const betrag = el('strong', 'spendenzeile__betrag');
|
||
betrag.append(window.Spendenkarte.dogenStueck(k.dogen_mille, k.zeichen));
|
||
const preis = alsPreis(k.dogen_mille);
|
||
if (preis) betrag.append(el('span', 'preis preis--unter', preis));
|
||
zeile.append(betrag);
|
||
const mitte = el('div', 'spendenzeile__mitte');
|
||
mitte.append(el('span', 'spendenzeile__wer', k.name || 'Jemand'));
|
||
if (k.stufe?.name) mitte.append(el('span', 'spendenzeile__gruss', k.stufe.name));
|
||
zeile.append(mitte);
|
||
const nochmal = el('button', 'spendenzeile__nochmal', 'Nochmal');
|
||
nochmal.type = 'button';
|
||
nochmal.title = 'Die Karte noch einmal zeigen';
|
||
nochmal.addEventListener('click', async () => {
|
||
const a = await hole(`/workspace/api/spenden/${k.id}/nochmal`, { method: 'POST' });
|
||
if (!a.ok) melde(await a.json().catch(() => null), 'Das ging nicht.');
|
||
});
|
||
zeile.append(nochmal);
|
||
letzte.append(zeile);
|
||
}
|
||
if (!(spendenLage.letzte || []).length) {
|
||
letzte.append(el('li', 'spendenliste__leer', 'Noch nichts gezeigt.'));
|
||
}
|
||
|
||
/* ---- Wer wie viele Dogen hat ----
|
||
Nur die Leitung sieht das. Wer nichts hat, steht nicht drin --
|
||
eine Liste mit lauter Nullen ist keine Auskunft, sondern eine
|
||
Personenliste mit einer Spalte daneben. */
|
||
const staende = $('sp-staende');
|
||
staende.textContent = '';
|
||
for (const p of (spendenLage.staende || [])) {
|
||
const zeile = el('li', 'dogenzeile');
|
||
zeile.append(el('span', 'dogenzeile__wer', p.name));
|
||
const wert = el('strong', 'dogenzeile__wert');
|
||
wert.append(window.Spendenkarte.dogenStueck(p.mille, spendenLage.zeichen));
|
||
const preis = alsPreis(p.mille);
|
||
if (preis) wert.append(el('span', 'preis preis--unter', preis));
|
||
zeile.append(wert);
|
||
staende.append(zeile);
|
||
}
|
||
if (!(spendenLage.staende || []).length) {
|
||
staende.append(el('li', 'spendenliste__leer',
|
||
'Noch niemand hat Dogen. Sie kommen dazu, sobald eine Spende '
|
||
+ 'einer Person zugeordnet ist.'));
|
||
}
|
||
|
||
/* ---- Wem die nächste Handeintragung gutgeschrieben wird ----
|
||
Die Auswahl wird nur neu gebaut, wenn sich die Namen geändert
|
||
haben: `spendenPultZeichnen()` läuft bei jeder Meldung, und
|
||
wer gerade eine Person ausgewählt hat, verlöre sie sonst
|
||
mitten im Eintragen. */
|
||
const wem = $('sp-wem');
|
||
const namen = (spendenLage.personen || []).map((p) => `${p.id}:${p.name}`).join('|');
|
||
if (wem.dataset.stand !== namen) {
|
||
wem.dataset.stand = namen;
|
||
const vorher = wem.value;
|
||
wem.textContent = '';
|
||
const leer = document.createElement('option');
|
||
leer.value = '';
|
||
leer.textContent = 'Niemandem';
|
||
wem.append(leer);
|
||
for (const p of (spendenLage.personen || [])) {
|
||
const o = document.createElement('option');
|
||
o.value = String(p.id);
|
||
o.textContent = p.name;
|
||
wem.append(o);
|
||
}
|
||
if (vorher) wem.value = vorher;
|
||
}
|
||
|
||
zeichenZeichnen();
|
||
|
||
$('sp-hinweis').textContent = spendenLage.paypal
|
||
? 'PayPal meldet uns nichts von selbst — die Karte läuft, wenn du sie '
|
||
+ 'einträgst oder eine Meldung bestätigst.'
|
||
: 'Auf der Unterstützen-Seite ist kein PayPal-Weg hinterlegt. '
|
||
+ 'Ohne ihn gibt es hier keine Spendenknöpfe.';
|
||
}
|
||
|
||
async function spendeEntscheiden(id, ja, felder) {
|
||
/* BEIDE WEGE STEHEN AUSGESCHRIEBEN DA. Aus einer Verzweigung
|
||
zusammengesetzt liessen sie sich nicht nachpruefen -- und ein
|
||
Tippfehler im selteneren Zweig („ablehnen") faellt erst auf,
|
||
wenn er mitten in einer Sendung gebraucht wird. */
|
||
const weg = ja
|
||
? `/workspace/api/spenden/${id}/bestaetigen`
|
||
: `/workspace/api/spenden/${id}/ablehnen`;
|
||
const a = await hole(weg, felder
|
||
? {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(felder),
|
||
}
|
||
: { method: 'POST' });
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
spendenHolen();
|
||
}
|
||
|
||
async function spendeEintragen() {
|
||
const mille = dogenInMille($('sp-betrag').value);
|
||
if (!mille) { sagFehler('Erst eine Zahl in Dogen eintragen.'); return; }
|
||
const name = $('sp-name').value.trim();
|
||
if (!name) { sagFehler('Wer hat gegeben? Der Name steht auf der Karte.'); return; }
|
||
const wem = $('sp-wem').value;
|
||
const a = await hole('/workspace/api/spenden', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
dogen_mille: mille, name, gruss: $('sp-gruss').value.trim(),
|
||
/* Nur mitschicken, wenn jemand ausgewählt ist. Eine leere
|
||
Kennung wäre eine Angabe über eine Person, die niemand
|
||
gemacht hat. */
|
||
...(wem ? { person_id: Number(wem) } : {}),
|
||
}),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
$('sp-betrag').value = ''; $('sp-name').value = ''; $('sp-gruss').value = '';
|
||
$('sp-wem').value = '';
|
||
spendenHolen();
|
||
}
|
||
|
||
function listeZeichnen() {
|
||
const liste = lage.liste || [];
|
||
const zahl = $('reiter-liste-zahl');
|
||
zahl.textContent = String(liste.length);
|
||
zahl.dataset.leer = liste.length ? 'nein' : 'ja';
|
||
/* Siehe oben: eine breitere Zahl verschiebt die Marke. */
|
||
reiterMarke();
|
||
|
||
const kasten = $('warteliste');
|
||
kasten.textContent = '';
|
||
if (!liste.length) {
|
||
const leer = el('li');
|
||
leer.append(el('p', 'warte-leer',
|
||
'Nichts vorbereitet. Häng oben eine YouTube-Adresse an — dann '
|
||
+ 'reicht im Live ein Druck auf „Nächstes".'));
|
||
kasten.append(leer);
|
||
return;
|
||
}
|
||
liste.forEach((z, i) => {
|
||
const zeile = el('li', 'warte-zeile');
|
||
zeile.append(el('span', 'warte-zeile__nr', String(i + 1)));
|
||
|
||
const bild = document.createElement('img');
|
||
bild.className = 'warte-zeile__bild';
|
||
bild.src = `https://i.ytimg.com/vi/${z.video}/mqdefault.jpg`;
|
||
bild.alt = '';
|
||
bild.loading = 'lazy';
|
||
bild.decoding = 'async';
|
||
zeile.append(bild);
|
||
|
||
const name = el('span', 'warte-zeile__titel', z.titel || z.video);
|
||
name.dataset.roh = z.titel ? 'nein' : 'ja';
|
||
name.title = z.titel || ('Kennung ' + z.video);
|
||
zeile.append(name);
|
||
|
||
const knoepfe = el('div', 'warte-zeile__knoepfe');
|
||
const jetzt = el('button', null, 'Jetzt');
|
||
jetzt.type = 'button';
|
||
jetzt.dataset.was = 'jetzt';
|
||
jetzt.title = 'Sofort dieses Video spielen';
|
||
jetzt.addEventListener('click', () => weiterSchalten(z.id));
|
||
|
||
const hoch = el('button', null, '↑');
|
||
hoch.type = 'button';
|
||
hoch.title = 'Nach oben';
|
||
hoch.disabled = i === 0;
|
||
hoch.addEventListener('click', () => listeRuf(
|
||
`/workspace/api/reaktion/liste/${z.id}/schieben`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ richtung: 'hoch' }),
|
||
}));
|
||
|
||
const runter = el('button', null, '↓');
|
||
runter.type = 'button';
|
||
runter.title = 'Nach unten';
|
||
runter.disabled = i === liste.length - 1;
|
||
runter.addEventListener('click', () => listeRuf(
|
||
`/workspace/api/reaktion/liste/${z.id}/schieben`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ richtung: 'runter' }),
|
||
}));
|
||
|
||
const weg = el('button', null, '×');
|
||
weg.type = 'button';
|
||
weg.title = 'Aus der Warteschlange nehmen';
|
||
weg.addEventListener('click', () => listeRuf(
|
||
'/workspace/api/reaktion/liste/' + z.id, { method: 'DELETE' }));
|
||
|
||
knoepfe.append(jetzt, hoch, runter, weg);
|
||
zeile.append(knoepfe);
|
||
kasten.append(zeile);
|
||
});
|
||
}
|
||
|
||
async function einstellen() {
|
||
try {
|
||
const beginn = $('p-beginn').value
|
||
? new Date($('p-beginn').value).toISOString() : null;
|
||
const a = await hole('/workspace/api/reaktion', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
titel: $('p-titel').value,
|
||
video: $('p-video').value,
|
||
vorschau: $('p-vorschau').value,
|
||
beginnt_am: beginn,
|
||
}),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = await a.json();
|
||
zeichnen();
|
||
sagFehler('Übernommen.');
|
||
} catch { sagFehler('Das ging gerade nicht.'); }
|
||
}
|
||
|
||
async function standSetzen(stand) {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/stand', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ stand }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = await a.json();
|
||
if (stand === 'zu') { draehteWeg(); }
|
||
zeichnen();
|
||
anschliessen();
|
||
} catch { sagFehler('Das ging gerade nicht.'); }
|
||
}
|
||
|
||
async function gastHolen() {
|
||
const id = Number($('p-wen').value);
|
||
if (!id) { sagFehler('Erst jemanden auswählen.'); return; }
|
||
const a = await hole('/workspace/api/reaktion/gast', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ person_id: id }),
|
||
});
|
||
if (!a.ok) melde(await a.json().catch(() => null), 'Das ging nicht.');
|
||
}
|
||
|
||
/* =================================================================
|
||
DER STROM — was der Server von sich aus schickt
|
||
================================================================= */
|
||
let strom = null;
|
||
let stromOffen = false;
|
||
function stromAuf() {
|
||
if (strom) return;
|
||
strom = new EventSource('/workspace/api/chat/strom');
|
||
/* WER GERADE ZUHOEREN KANN, FRAGT NACH, WAS ER VERPASST HAT
|
||
(30.09.2026).
|
||
|
||
Zwischen dem Laden der Seite und dem Offensein des Stroms
|
||
liegt ein Fenster. Was die Regie darin umschaltet, kam bei
|
||
dieser Seite NIE an: Der Rundruf ging hinaus, als noch niemand
|
||
zuhoerte, und danach fragte niemand mehr nach.
|
||
|
||
Dasselbe nach jedem Abriss — der Browser baut den Strom von
|
||
selbst wieder auf, aber alles aus der Unterbrechung ist weg.
|
||
Die Seite zeigte dann einen Stand von vor dem Abriss, und zwar
|
||
unbegrenzt: Ein Zuschauer lädt eine Stunde lang nicht neu.
|
||
|
||
Eine kleine Abfrage je Verbindung macht die Seite
|
||
selbstheilend. Die Alternative wäre, jedem Rundruf zu
|
||
glauben, dass er angekommen ist — und genau das war der
|
||
Fehler. */
|
||
strom.addEventListener('open', () => {
|
||
stromOffen = true;
|
||
/* SANFT: Diese Abfrage macht die Seite von sich aus. Kommt
|
||
währenddessen ein Rundruf, ist der neuer — dann gilt er. */
|
||
standHolen(true);
|
||
});
|
||
strom.addEventListener('reaktion', (e) => {
|
||
try {
|
||
/* JEDER RUNDRUF ZAEHLT — an EINER Stelle, ganz oben. In
|
||
jeden Zweig einzeln geschrieben wäre es die Zeile, die
|
||
beim nächsten Zweig vergessen wird. Dass dabei auch
|
||
Rundrufe mitzählen, die `lage` gar nicht anfassen, ist
|
||
die sichere Richtung: Im Zweifel behält die Seite, was
|
||
sie über den Strom weiß. */
|
||
standFolge++;
|
||
const d = JSON.parse(e.data);
|
||
if (d.was === 'video') {
|
||
/* WER STEUERT, FOLGT SEINEM EIGENEN ECHO NICHT (29.09.2026).
|
||
|
||
`empfaenger()` fügt den Host ausdrücklich hinzu, damit
|
||
ein zweites Gerät von ihm mitläuft. Dadurch kam seine
|
||
eigene Meldung auch bei ihm selbst an — und `folgen()`
|
||
hat sie auf ihn angewandt. Meldete er einmal „läuft
|
||
nicht" (etwa beim Puffern), hielt ihn eine Sekunde später
|
||
sein eigenes Echo an.
|
||
|
||
GEPRÜFT WIRD DIE KENNUNG, NICHT DIE ROLLE. `ich.host` ist
|
||
bei DogFather UND bei seiner rechten Hand wahr — beide
|
||
dürfen die Sendung führen. Geführt wird sie aber von
|
||
genau einem, und nur der darf sein Echo ausschlagen. Wer
|
||
danebensteht, muss weiter folgen, sonst sieht er etwas
|
||
anderes als alle übrigen.
|
||
|
||
DAS TEMPO GILT AUCH FÜR IHN, und zwar ausdrücklich.
|
||
Hier stand erst „das zieht trotzdem mit — es steht
|
||
darunter". Das war falsch: Darunter wird `lage.tempo`
|
||
gesetzt und das Schild neu gezeichnet; den SPIELER
|
||
stellt allein `tempoNachziehen()`, und die steht in
|
||
`folgen()`. Gemessen lief 2× bei ihm danach mit 0,50
|
||
statt 2,00 Sekunden je Sekunde — er war der Einzige, bei
|
||
dem die Geschwindigkeit nichts tat.
|
||
|
||
Das Tempo kommt nicht aus seinem Spieler zurück, sondern
|
||
aus dem Pult. Es ist kein Echo seiner selbst, sondern
|
||
eine Ansage. */
|
||
/* DER ANKER ZUERST, und zwar fuer JEDEN -- auch fuer den,
|
||
der fuehrt. Er ist die Beschreibung dessen, was gerade
|
||
laeuft, nicht eine Anweisung an den Spieler. Wer sie
|
||
nicht mitschreibt, hat nach einem Wechsel der Fuehrung
|
||
keinen Stand.
|
||
|
||
HIER OBEN, weil `seit` eine Stoppuhr ist: Sie gehoert in
|
||
den Augenblick der Ankunft. */
|
||
ankerSetzen(d);
|
||
if (lage?.host?.id && lage.host.id === meineId) {
|
||
if (d.tempo) tempoNachziehen(d.tempo);
|
||
} else {
|
||
folgen(d);
|
||
}
|
||
/* DAS SCHILD MUSS MIT. `folgen()` stellt den Spieler, aber
|
||
der Rundruf geht hier mit `return` heraus -- ohne diese
|
||
zwei Zeilen stuende beim Zuschauer weiter „1x", waehrend
|
||
das Video schon auf 1,5 laeuft. Genau die Sorte
|
||
Widerspruch, die man fuer einen Defekt haelt. */
|
||
if (lage && d.tempo && d.tempo !== lage.tempo) {
|
||
lage = { ...lage, tempo: d.tempo };
|
||
zeichnen();
|
||
}
|
||
return;
|
||
}
|
||
if (d.was === 'layout') {
|
||
/* DER RUNDRUF BRINGT DAS GANZE BILD, nicht nur die
|
||
Anordnung. Vorher stand hier eine Zeile für `layout`;
|
||
als Größe und Ecke dazukamen, hätten die Zusehenden die
|
||
neue Anordnung mit der alten Ecke gesehen -- bis zum
|
||
nächsten Mal, wenn zufällig etwas anderes den ganzen
|
||
Stand nachzieht. */
|
||
if (lage) {
|
||
lage = {
|
||
...lage,
|
||
layout: d.layout,
|
||
kamera_gross: d.kamera_gross ?? lage.kamera_gross,
|
||
kamera_ecke: d.kamera_ecke ?? lage.kamera_ecke,
|
||
};
|
||
zeichnen();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (d.was === 'werbung') {
|
||
/* DER RUNDRUF BRINGT DIE GANZE EINBLENDUNG -- Art, Tempo,
|
||
beide Ecken UND die Botschaften. Nur die Kennungen zu
|
||
schicken waere kuerzer und falsch: Wer eine Botschaft
|
||
aendert, saehe sie erst beim naechsten Neuladen, und ein
|
||
Zuschauer laedt eine Stunde lang nicht neu.
|
||
|
||
`banner_alle` ist NICHT dabei -- die Verwaltungsliste
|
||
samt abgeschalteten Entwuerfen gehoert nur der Regie.
|
||
Wer sie braucht, holt den Stand.
|
||
|
||
KOMMT ER VOR DEM STAND AN, wird der ganze Stand geholt
|
||
statt den Rundruf wegzuwerfen (30.09.2026). Der
|
||
Ereignisstrom haengt beim Aufbau der Seite; `lage` ist
|
||
erst da, wenn `standHolen()` einmal durch ist. In dem
|
||
Fenster dazwischen ging jeder Rundruf still verloren --
|
||
wer die Seite gerade aufmachte, waehrend die Regie
|
||
umschaltete, sah die Werbung nicht, und niemand merkte
|
||
es.
|
||
|
||
Die Messung hat das als scheinbare Flatterhaftigkeit
|
||
gezeigt: einmal fehlte das Laufband, beim naechsten Lauf
|
||
der Wechsel. Ein Ergebnis, das zwischen zwei Laeufen
|
||
springt, ist zuerst als Wettlauf verdaechtig. */
|
||
if (!lage) { standHolen(); return; }
|
||
{
|
||
lage = { ...lage, werbung: {
|
||
art: d.art, tempo: d.tempo, ecke_dogi: d.ecke_dogi,
|
||
ecke_hase: d.ecke_hase, ecke_gross: d.ecke_gross, band: d.band,
|
||
} };
|
||
werbungZeichnen();
|
||
if (lage.ich?.host) standHolen();
|
||
}
|
||
return;
|
||
}
|
||
|
||
/* ==== MODERATION ==========================================
|
||
|
||
Drei Meldungen ohne Stand im Gepäck, und alle drei aus
|
||
demselben Grund: Was sie betrifft, steht NICHT im Rundruf.
|
||
`meldungen` und `massnahmen` sind aus `oeffentlich()`
|
||
ausgenommen, weil dort der gemeldete Text und der Name des
|
||
Melders stehen. Wer sie sehen darf, holt sie sich selbst. */
|
||
if (d.was === 'massnahme') { standHolen(); return; }
|
||
if (d.was === 'meldung') {
|
||
if (lage && lage.ich.moderiert) standHolen();
|
||
return;
|
||
}
|
||
if (d.was === 'moderation') {
|
||
if (lage && lage.ich.moderiert) standHolen();
|
||
/* Für alle anderen ist trotzdem etwas passiert: Wer gerade
|
||
stumm geschaltet wurde, erfährt es über den eigenen
|
||
Stand, und wer rausgenommen wurde, muss es sofort sehen. */
|
||
return;
|
||
}
|
||
if (d.lage) {
|
||
/* Der Rundruf enthält keine Namensliste und kein „ich" --
|
||
beides gehört nur dem Empfänger. Was hier ankommt, wird
|
||
daher ÜBER den eigenen Stand gelegt, nicht an seine
|
||
Stelle gesetzt. */
|
||
/* `ich`, `zuschauer` und `liste` stehen NICHT im Rundruf --
|
||
sie gehen nur an den, der sie sehen darf (siehe
|
||
`oeffentlich()` im Server). Würden sie hier nicht
|
||
hinübergerettet, verlöre die Regie bei jeder fremden
|
||
Änderung ihre Warteschlange und ihre Namensliste. */
|
||
lage = {
|
||
...lage, ...d.lage,
|
||
ich: lage.ich, zuschauer: lage.zuschauer, liste: lage.liste,
|
||
};
|
||
zeichnen();
|
||
anschliessen();
|
||
/* Die eigene Rolle kann sich geändert haben (dazugeholt oder
|
||
entlassen). Das steht nur im eigenen Stand. */
|
||
if (d.was === 'du_bist_gast' || d.was === 'du_bist_kein_gast') standHolen();
|
||
if (d.was === 'dabei' || d.was === 'weg') { if (lage.ich.host) standHolen(); }
|
||
/* Hat die andere Regie etwas an der Warteschlange geändert,
|
||
muss ich sie neu holen -- im Rundruf steht sie nicht. */
|
||
if (d.was === 'liste' && lage.ich.host) standHolen();
|
||
}
|
||
} catch { /* eine kaputte Meldung darf die Seite nicht kippen */ }
|
||
});
|
||
strom.addEventListener('reaktion_chat', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
if (d.was === 'neu') chatAnhaengen(d.beitrag);
|
||
if (d.was === 'weg') {
|
||
const w = $('chat-liste').querySelector(`.beitrag[data-id="${d.id}"]`);
|
||
if (w) w.remove();
|
||
}
|
||
} catch { /* siehe oben */ }
|
||
});
|
||
/* ==== DIE SPENDENKARTEN ========================================
|
||
|
||
Der Strom bringt zwei Sorten: „zeigen" laesst die Karte
|
||
laufen -- bei ALLEN, die zusehen --, „offen" sagt der Leitung,
|
||
dass eine Meldung wartet. */
|
||
strom.addEventListener('spende', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
if (d.was === 'zeigen' && window.Spendenkarte) {
|
||
window.Spendenkarte.zeigen($('spendentafel'), d.karte);
|
||
if (lage && lage.ich.host) spendenHolen();
|
||
}
|
||
if (d.was === 'offen') spendenHolen();
|
||
/* Ein gewechselter Muenzsatz gilt fuer alle -- auch fuer
|
||
die, die gerade keine Spende sehen. */
|
||
if (d.was === 'zeichen') spendenHolen();
|
||
} catch { /* eine kaputte Meldung darf den Strom nicht reissen */ }
|
||
});
|
||
|
||
/* MEIN EIGENER DOGEN-STAND. Er kommt als eigene Meldung und nur
|
||
an mich -- eine Zahl, die niemanden sonst etwas angeht. */
|
||
strom.addEventListener('dogen', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
if (!spendenLage) return;
|
||
spendenLage.ich = { ...(spendenLage.ich || {}), dogen_mille: d.mille };
|
||
standZeigen();
|
||
} catch { /* eine kaputte Meldung darf den Strom nicht reissen */ }
|
||
});
|
||
|
||
strom.addEventListener('reaktion_signal', (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data);
|
||
signalEmpfangen(d.von, d.daten);
|
||
} catch { /* siehe oben */ }
|
||
});
|
||
}
|
||
|
||
/* =================================================================
|
||
DER TAKT — ich bin noch da
|
||
================================================================= */
|
||
let dabeiUhr = null;
|
||
/* Die langsame Nachfrage hinter dem Vorhang -- siehe
|
||
`rausgenommen()`. */
|
||
let vorhangUhr = null;
|
||
let draehteUhr = null;
|
||
|
||
/** Wartet, bis der Ereignisstrom offen ist -- höchstens drei
|
||
* Sekunden.
|
||
*
|
||
* ERST ZUHÖREN, DANN BESCHEID SAGEN: Wer sich mit `/dabei`
|
||
* anmeldet, bevor sein Strom steht, bekommt das Angebot des Hosts
|
||
* nicht -- der schickt es nämlich sofort, sobald er von ihm
|
||
* erfährt. Gemessen: genau so blieb jedes Kamerafenster schwarz.
|
||
*
|
||
* DREI SEKUNDEN UND DANN TROTZDEM WEITER. Ein Strom, der nicht
|
||
* aufgeht, darf die Seite nicht anhalten -- Chat und Video laufen
|
||
* auch ohne ihn, und die Rücknahme oben heilt den Rest. */
|
||
async function stromAbwarten(frist = 3000) {
|
||
const bis = Date.now() + frist;
|
||
while (!stromOffen && Date.now() < bis) {
|
||
await new Promise((r) => setTimeout(r, 80));
|
||
}
|
||
return stromOffen;
|
||
}
|
||
|
||
async function anschliessen() {
|
||
clearInterval(dabeiUhr);
|
||
if (!lage || lage.stand === 'zu') {
|
||
clearInterval(draehteUhr);
|
||
draehteWeg();
|
||
|
||
/* ABER NICHT VERSTUMMEN (30.09.2026).
|
||
|
||
Hier stand ein blankes `return` — und damit fragte die Seite
|
||
nie wieder nach. Rundrufe bekommt sie in diesem Zustand auch
|
||
keine: Wer nicht in `reaktion_dabei` steht, steht nicht in
|
||
`empfaenger()`, und beim Schließen wird genau diese Liste
|
||
geleert.
|
||
|
||
Folge: Macht der Host wieder auf, passiert bei allen, die den
|
||
Reiter noch offen haben, NICHTS. Nicht später, nicht in
|
||
dreißig Sekunden — nie, bis jemand von Hand neu lädt.
|
||
Gemessen: Server sagt „vorbereitung", die Seite zeigt „zu",
|
||
auch nach 40 Sekunden.
|
||
|
||
Ein geschlossener Saal ist der ruhigste Fall des ganzen
|
||
Hauses: kein Video, keine Kameras, kein Chat. Eine kleine
|
||
Abfrage alle fünfzehn Sekunden kostet dort nichts — und ist
|
||
der Unterschied zwischen „es geht los" und „lad mal neu".
|
||
|
||
`anschliessen()` ruft sich danach selbst: Ist wieder
|
||
aufgemacht, räumt dieser Aufruf den langsamen Takt weg und
|
||
startet den richtigen. `standHolen()` allein täte das nicht
|
||
— es zeichnet nur. */
|
||
dabeiUhr = setInterval(async () => {
|
||
await standHolen(true);
|
||
anschliessen();
|
||
}, 15_000);
|
||
return;
|
||
}
|
||
await stromAbwarten();
|
||
|
||
const melden = async () => {
|
||
try {
|
||
/* DIE FOLGENUMMER GILT AUCH HIER (30.09.2026). Dieser Takt
|
||
laeuft auf jeder Zuschauerseite alle 30 Sekunden und
|
||
ersetzt den GANZEN Stand. Ist er unterwegs, waehrend ein
|
||
Rundruf ankommt, gewinnt er -- obwohl der Rundruf das
|
||
Neuere wusste. Gemessen: Der Wechsel der Werbung wurde
|
||
gezeichnet und war einen Augenblick spaeter wieder leer. */
|
||
const beim = standFolge;
|
||
const a = await hole('/workspace/api/reaktion/dabei', { method: 'POST' });
|
||
if (a.ok) {
|
||
const neu = await a.json();
|
||
if (beim === standFolge) { lage = neu; zeichnen(); }
|
||
/* Die Verbindungen werden in jedem Fall nachgezogen -- das
|
||
ist kein Stand, sondern der Draht dorthin. */
|
||
draehteNachziehen();
|
||
return;
|
||
}
|
||
/* HINAUSGENOMMEN ODER GESPERRT. Ohne diesen Zweig läuft das
|
||
Video weiter, der Saal sieht normal aus, und nur die
|
||
Anwesenheit schlägt still fehl -- die Person wäre
|
||
„rausgenommen" und würde davon nichts merken. Das ist
|
||
dieselbe Sorte Fehler wie ein stummes Eingabefeld: Es sieht
|
||
aus wie ein Defekt, nicht wie eine Entscheidung. */
|
||
if (a.status === 403) {
|
||
const wort = (await a.json().catch(() => null))?.fehler;
|
||
if (wort === 'aus_der_sendung' || wort === 'treff_massnahme') rausgenommen(wort);
|
||
return;
|
||
}
|
||
|
||
/* JEDE ANDERE ABSAGE IST AUCH EINE AUSKUNFT (30.09.2026).
|
||
|
||
Der wichtigste Fall ist 409 „saal_zu". Ist der Saal zu,
|
||
steht diese Person in keiner Dabei-Liste mehr — sie bekommt
|
||
also auch keinen Rundruf, wenn wieder aufgemacht wird. Ihr
|
||
Takt lief weiter, wurde jedes Mal abgewiesen, und die Seite
|
||
blieb stehen. Nicht dreißig Sekunden: für immer, bis
|
||
jemand neu lädt.
|
||
|
||
Gemessen: Nach dem Schließen und Wiederaufmachen stand die
|
||
Seite der Zuschauerin auch nach 40 Sekunden noch auf „zu".
|
||
|
||
Wer nicht sagen kann, ob er dabei ist, holt den Stand.
|
||
Eine kleine Abfrage je halber Minute — und der Unterschied
|
||
zwischen einer Seite, die sich fängt, und einer, die man
|
||
neu laden muss.
|
||
|
||
SANFT, wie jede Abfrage, die die Seite von sich aus
|
||
macht. */
|
||
standHolen(true);
|
||
} catch { /* der nächste Takt versucht es wieder */ }
|
||
};
|
||
melden();
|
||
dabeiUhr = setInterval(melden, 30_000);
|
||
|
||
/* EIN EIGENER, SCHNELLER TAKT FUER DIE VERBINDUNGEN.
|
||
|
||
Die Anwesenheit reicht alle 30 Sekunden -- eine fehlende
|
||
Verbindung nicht. Wer 30 Sekunden auf ein schwarzes Fenster
|
||
schaut, glaubt, es sei kaputt. Dieser Takt kostet nichts,
|
||
solange alles steht: `draehteNachziehen` schickt nur dann
|
||
etwas, wenn wirklich jemand fehlt. */
|
||
clearInterval(draehteUhr);
|
||
draehteUhr = setInterval(draehteNachziehen, 2500);
|
||
|
||
if (lage.stand === 'live') {
|
||
apiLaden();
|
||
if (binSender) kameraHolen();
|
||
}
|
||
}
|
||
|
||
/* =================================================================
|
||
PAYPAL
|
||
|
||
Der Weg kommt aus der Unterstützen-Seite -- derselbe Eintrag,
|
||
dieselbe Pflege, eine Wahrheit. Ist dort nichts hinterlegt oder
|
||
ist er auf unsichtbar gestellt, erscheint hier kein Knopf. Das
|
||
ist Absicht: Eine geratene Adresse ist an dieser Stelle die
|
||
gefährlichste aller Abkürzungen.
|
||
================================================================= */
|
||
let spendenLage = null;
|
||
let gemeldeterBetrag = 0;
|
||
|
||
/* =================================================================
|
||
DIE STUFENLEITER
|
||
|
||
Filipe: „mach paar fertige und so dass ich auch hochladen kann.
|
||
auch so dass ich das anders gestalten kann oder die größe
|
||
verändern kann. ... auch spezielle sachen bei speziellen spenden."
|
||
|
||
ALLES AUF EINMAL SPEICHERN UND NICHT ZEILE FÜR ZEILE. Eine
|
||
Stufenleiter ist keine Liste unabhängiger Zeilen — sie muss als
|
||
GANZES stimmen: aufsteigende Grenzen, keine zwei mit demselben
|
||
Betrag, mindestens eine. Wer einzeln speichert, geht durch
|
||
Zwischenzustände, in denen sie das nicht tut, und mitten in einer
|
||
Sendung fiele dann eine Spende in die falsche Stufe.
|
||
|
||
Das Bild ist die Ausnahme: Es wird sofort hochgeladen. Eine
|
||
Datei in einem Formular zwischenzuhalten, bis jemand
|
||
„Übernehmen" drückt, heißt, sie bei jedem Versehen wieder zu
|
||
verlieren. */
|
||
let stufen = null;
|
||
|
||
function stufenZeichnen() {
|
||
const kasten = $('stufen');
|
||
if (!kasten || !stufen) return;
|
||
const g = stufen.gestaltung;
|
||
kasten.textContent = '';
|
||
|
||
stufen.liste.forEach((st, i) => {
|
||
const zeile = el('div', 'stufe-zeile');
|
||
zeile.dataset.platz = String(i);
|
||
|
||
const feld = (wort, knoten) => {
|
||
const l = el('label', 'stufe-feld');
|
||
l.append(el('span', null, wort));
|
||
l.append(knoten);
|
||
return l;
|
||
};
|
||
const eingabe = (art, wert, wie) => {
|
||
const e = document.createElement('input');
|
||
e.type = art;
|
||
e.value = wert;
|
||
Object.assign(e, wie || {});
|
||
e.addEventListener('input', () => { stufenGeaendert(); });
|
||
return e;
|
||
};
|
||
|
||
/* Die Grenze steht in DOGEN — im Feld wie in der Leitung.
|
||
Diese Seite kennt keinen anderen Wert mehr. */
|
||
/* ==== WAS EINE STUFE IN GELD BEDEUTET ==================
|
||
Filipe: „da muss ich auch sehen so viel dogen sind so viel
|
||
euro. damit ich auch immer weiss was es ist."
|
||
|
||
Direkt am Feld und nicht in einer Fußzeile: Wer eine Grenze
|
||
verschiebt, will im selben Blick sehen, was er verschoben
|
||
hat. Die Zahl rechnet beim Tippen mit -- eine Umrechnung,
|
||
die erst nach dem Speichern stimmt, kommt zu spät. */
|
||
const abFeld = eingabe('text', milleAlsWort(st.ab_mille),
|
||
{ inputMode: 'decimal', className: 'stufe-ab' });
|
||
const abPreis = el('span', 'preis', alsPreis(st.ab_mille));
|
||
abFeld.addEventListener('input', () => {
|
||
abPreis.textContent = alsPreis(dogenInMille(abFeld.value));
|
||
});
|
||
const abKasten = el('label', 'stufe-feld');
|
||
const abWort = el('span');
|
||
abWort.append(document.createTextNode('Ab Dogen '));
|
||
abWort.append(abPreis);
|
||
abKasten.append(abWort);
|
||
abKasten.append(abFeld);
|
||
zeile.append(abKasten);
|
||
zeile.append(feld('Name', eingabe('text', st.name,
|
||
{ maxLength: 40, className: 'stufe-name' })));
|
||
|
||
const wahl = document.createElement('select');
|
||
wahl.className = 'stufe-vorlage';
|
||
for (const v of g.vorlagen) {
|
||
const o = document.createElement('option');
|
||
o.value = v;
|
||
o.textContent = v.charAt(0).toUpperCase() + v.slice(1);
|
||
wahl.append(o);
|
||
}
|
||
wahl.value = st.vorlage;
|
||
wahl.addEventListener('change', stufenGeaendert);
|
||
zeile.append(feld('Zeichen', wahl));
|
||
|
||
zeile.append(feld('Farbe', eingabe('color', st.farbe, { className: 'stufe-farbe' })));
|
||
zeile.append(feld('Sekunden', eingabe('number', String(Math.round(st.dauer_ms / 1000)), {
|
||
min: String(Math.round(g.dauer_min / 1000)),
|
||
max: String(Math.round(g.dauer_max / 1000)),
|
||
step: '1', className: 'stufe-dauer',
|
||
})));
|
||
zeile.append(feld('Größe', eingabe('number', String(st.groesse), {
|
||
min: String(g.groesse_min), max: String(g.groesse_max),
|
||
step: '0.05', className: 'stufe-groesse',
|
||
})));
|
||
|
||
/* ---- Das eigene Bild ---- */
|
||
const bildTeil = el('div', 'stufe-bild');
|
||
if (st.bild) {
|
||
const b = document.createElement('img');
|
||
b.src = st.bild;
|
||
b.alt = '';
|
||
bildTeil.append(b);
|
||
}
|
||
const hoch = el('label', 'stufe-tun', st.bild ? 'Anderes Bild' : 'Bild hochladen');
|
||
const wahlfeld = document.createElement('input');
|
||
wahlfeld.type = 'file';
|
||
wahlfeld.accept = 'image/jpeg,image/png,image/webp';
|
||
wahlfeld.hidden = true;
|
||
wahlfeld.addEventListener('change', () => bildHoch(st, wahlfeld));
|
||
hoch.append(wahlfeld);
|
||
/* NUR WER SCHON GESPEICHERT IST, KANN EIN BILD BEKOMMEN. Eine
|
||
frische Zeile hat noch keine Kennung, an der es hängen
|
||
könnte -- der Knopf sagt das, statt ins Leere zu greifen. */
|
||
if (!st.id) {
|
||
hoch.classList.add('stufe-tun--aus');
|
||
hoch.textContent = 'Erst übernehmen';
|
||
wahlfeld.disabled = true;
|
||
}
|
||
bildTeil.append(hoch);
|
||
if (st.bild) {
|
||
const weg = el('button', 'stufe-tun stufe-tun--weg', 'Bild weg');
|
||
weg.type = 'button';
|
||
weg.addEventListener('click', () => bildWeg(st));
|
||
bildTeil.append(weg);
|
||
}
|
||
zeile.append(bildTeil);
|
||
|
||
const probe = el('button', 'stufe-tun', 'Ausprobieren');
|
||
probe.type = 'button';
|
||
probe.title = 'Zeigt dir eine Karte — es wird keine Spende daraus';
|
||
probe.addEventListener('click', () => stufeProbe(st));
|
||
zeile.append(probe);
|
||
|
||
const weg = el('button', 'stufe-tun stufe-tun--weg', 'Stufe weg');
|
||
weg.type = 'button';
|
||
weg.addEventListener('click', () => { stufen.liste.splice(i, 1); stufenZeichnen(); stufenGeaendert(); });
|
||
zeile.append(weg);
|
||
|
||
kasten.append(zeile);
|
||
});
|
||
|
||
$('stufe-neu').disabled = stufen.liste.length >= stufen.gestaltung.stufen_max;
|
||
}
|
||
|
||
function stufenGeaendert() {
|
||
const st = $('stufen-stand');
|
||
if (st) { st.textContent = 'Noch nicht übernommen'; st.dataset.offen = 'ja'; }
|
||
}
|
||
|
||
function stufeDazu() {
|
||
if (!stufen || stufen.liste.length >= stufen.gestaltung.stufen_max) return;
|
||
const hoechste = stufen.liste.reduce((a, b) => Math.max(a, b.ab_mille), 0);
|
||
stufen.liste.push({
|
||
id: 0, ab_mille: hoechste ? hoechste * 2 : 100, name: 'Neue Stufe',
|
||
vorlage: 'stern', bild: '', farbe: '#8ec9ff', dauer_ms: 7000, groesse: 1,
|
||
});
|
||
stufenZeichnen();
|
||
stufenGeaendert();
|
||
}
|
||
|
||
/** Was in den Feldern steht, zurück in Zahlen. */
|
||
function stufenAusFeldern() {
|
||
return [...$('stufen').children].map((zeile, i) => ({
|
||
id: stufen.liste[i]?.id || 0,
|
||
ab_mille: dogenInMille(zeile.querySelector('.stufe-ab').value),
|
||
name: zeile.querySelector('.stufe-name').value.trim(),
|
||
vorlage: zeile.querySelector('.stufe-vorlage').value,
|
||
farbe: zeile.querySelector('.stufe-farbe').value,
|
||
dauer_ms: Math.round(Number(zeile.querySelector('.stufe-dauer').value) * 1000),
|
||
groesse: Number(zeile.querySelector('.stufe-groesse').value),
|
||
}));
|
||
}
|
||
|
||
async function stufenSichern() {
|
||
const liste = stufenAusFeldern();
|
||
const a = await hole('/workspace/api/spenden/stufen', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ stufen: liste }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
const d = await a.json();
|
||
stufen.liste = d.stufen;
|
||
stufenZeichnen();
|
||
const st = $('stufen-stand');
|
||
st.textContent = 'Übernommen';
|
||
st.dataset.offen = 'nein';
|
||
}
|
||
|
||
async function bildHoch(stufe, feld) {
|
||
const datei = feld.files && feld.files[0];
|
||
if (!datei) return;
|
||
feld.value = '';
|
||
const a = await hole(`/workspace/api/spenden/stufe/${stufe.id}/bild`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': datei.type || 'application/octet-stream' },
|
||
body: datei,
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das Bild ging nicht.'); return; }
|
||
const d = await a.json();
|
||
stufen.liste = d.stufen;
|
||
stufenZeichnen();
|
||
}
|
||
|
||
async function bildWeg(stufe) {
|
||
const a = await hole(`/workspace/api/spenden/stufe/${stufe.id}/bild`, { method: 'DELETE' });
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
const d = await a.json();
|
||
stufen.liste = d.stufen;
|
||
stufenZeichnen();
|
||
}
|
||
|
||
/** Einmal ansehen -- ohne dass eine Spende daraus wird.
|
||
*
|
||
* DER NAHELIEGENDE WEG WÄRE FALSCH GEWESEN: eine Spende von Hand
|
||
* eintragen und danach löschen. In ein laufendes System kommen
|
||
* keine Testdaten, und „gelöscht" heißt bei Geld nicht „war nie
|
||
* da" -- der Eintrag stünde im Verlauf und in der Aufbewahrung. */
|
||
async function stufeProbe(stufe) {
|
||
const a = await hole('/workspace/api/spenden/probe', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ dogen_mille: stufe.ab_mille }),
|
||
});
|
||
if (!a.ok) melde(await a.json().catch(() => null), 'Das ging nicht.');
|
||
}
|
||
|
||
/* ==== DIE DREI MÜNZSÄTZE =========================================
|
||
|
||
Filipe: „alle drei varianten will ich drauf so dass ich sie
|
||
aussuchen kann wie ich will."
|
||
|
||
Jeder Satz steht mit seinen DREI Münzen da, von links nach
|
||
rechts aufsteigend. Ein Satz, der nur mit einer Münze
|
||
vorgestellt wird, sagt nichts über den Unterschied zwischen
|
||
niedrig und hoch — und genau der ist hier die Aussage. */
|
||
function zeichenZeichnen() {
|
||
const kasten = $('muenzwahl');
|
||
if (!kasten || !spendenLage) return;
|
||
const jetzt = spendenLage.zeichen?.satz || '';
|
||
kasten.textContent = '';
|
||
for (const satz of (spendenLage.saetze || [])) {
|
||
const k = el('button', 'muenzsatz');
|
||
k.type = 'button';
|
||
k.dataset.satz = satz.schluessel;
|
||
k.setAttribute('aria-pressed', String(satz.schluessel === jetzt));
|
||
|
||
const reihe = el('div', 'muenzsatz__reihe');
|
||
for (const stufe of ['niedrig', 'mittel', 'hoch']) {
|
||
const b = document.createElement('img');
|
||
b.className = 'muenzsatz__bild';
|
||
b.src = satz.bilder?.[stufe] || '';
|
||
b.alt = '';
|
||
/* Erst laden, wenn das Register „Gestaltung" offen ist. Neun
|
||
Münzen beim Seitenaufbau wären knapp ein halbes Megabyte
|
||
für etwas, das die meisten nie ansehen. */
|
||
b.loading = 'lazy';
|
||
reihe.append(b);
|
||
}
|
||
k.append(reihe);
|
||
|
||
const text = el('div', 'muenzsatz__text');
|
||
text.append(el('strong', 'muenzsatz__name', satz.name));
|
||
text.append(el('span', 'muenzsatz__wort', satz.wort));
|
||
k.append(text);
|
||
|
||
k.addEventListener('click', () => satzSetzen(satz.schluessel));
|
||
kasten.append(k);
|
||
}
|
||
$('zeichen-weg').hidden = !spendenLage.zeichen?.eigen;
|
||
$('zeichen-hoch').firstChild.textContent = spendenLage.zeichen?.eigen
|
||
? 'Anderes eigenes Zeichen ' : 'Eigenes Zeichen hochladen ';
|
||
}
|
||
|
||
async function satzSetzen(satz) {
|
||
const a = await hole('/workspace/api/spenden/zeichen/satz', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ satz }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
/* FRISCH HOLEN. Die Münze steht an sechs Stellen gleichzeitig --
|
||
auf den Karten, in beiden Listen, an den Knöpfen, beim eigenen
|
||
Stand und in dieser Auswahl. Sie einzeln nachzuziehen wäre die
|
||
Gelegenheit, eine zu vergessen. */
|
||
spendenHolen();
|
||
}
|
||
|
||
async function zeichenHoch(feld) {
|
||
const datei = feld.files && feld.files[0];
|
||
if (!datei) return;
|
||
feld.value = '';
|
||
const a = await hole('/workspace/api/spenden/zeichen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': datei.type || 'application/octet-stream' },
|
||
body: datei,
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das Bild ging nicht.'); return; }
|
||
/* FRISCH HOLEN UND NICHT NUR HIER EINSETZEN. Das Zeichen steht an
|
||
fünf Stellen gleichzeitig — auf den Karten, in beiden Listen,
|
||
an den Knöpfen und beim eigenen Stand. Sie alle einzeln
|
||
nachzuziehen wäre die Gelegenheit, eine zu vergessen. */
|
||
spendenHolen();
|
||
}
|
||
|
||
async function zeichenWeg() {
|
||
const a = await hole('/workspace/api/spenden/zeichen', { method: 'DELETE' });
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
spendenHolen();
|
||
}
|
||
|
||
async function spendenHolen() {
|
||
try {
|
||
const a = await hole('/workspace/api/spenden');
|
||
if (!a.ok) return;
|
||
spendenLage = await a.json();
|
||
gebenZeichnen();
|
||
standZeigen();
|
||
if (lage && lage.ich.host) spendenPultZeichnen();
|
||
/* DIE STUFENLEITER NUR BEIM ERSTEN MAL AUS DER LEITUNG NEHMEN.
|
||
Wer gerade an den Feldern tippt und dabei eine Spende
|
||
hereinbekommt, verlöre sonst mitten im Satz, was er
|
||
eingetragen hat -- `spendenHolen()` läuft bei jeder
|
||
Meldung. */
|
||
if (lage && lage.ich.host && spendenLage.gestaltung && !stufen) {
|
||
stufen = { liste: spendenLage.stufen, gestaltung: spendenLage.gestaltung };
|
||
stufenZeichnen();
|
||
}
|
||
} catch { /* ohne Feld ist die Seite vollständig */ }
|
||
}
|
||
|
||
/** Vier Beträge, ein freier Weg — und danach EINE Frage.
|
||
*
|
||
* Mehr können wir von hier aus nicht wissen: PayPal sagt uns
|
||
* nichts, solange kein Webhook eingerichtet ist. So zu tun, als
|
||
* wüssten wir es, wäre gelogen — also fragt das Feld, und die
|
||
* Antwort wartet im Pult auf eine Bestätigung. */
|
||
function gebenZeichnen() {
|
||
const kasten = $('geben');
|
||
if (!kasten || !spendenLage) return;
|
||
if (!spendenLage.paypal) { kasten.hidden = true; return; }
|
||
kasten.hidden = false;
|
||
$('geben-titel').textContent = spendenLage.paypal.knopf || 'Dogi was ausgeben';
|
||
|
||
const reihe = $('geben-betraege');
|
||
if (!reihe.children.length) {
|
||
for (const b of (spendenLage.knoepfe || [])) {
|
||
const k = el('a', 'geben__betrag');
|
||
k.append(window.Spendenkarte.dogenStueck(b.mille, spendenLage.zeichen,
|
||
'dogen--nurzahl'));
|
||
/* ==== DER PREIS STEHT AM KNOPF =========================
|
||
Filipe: „die leute sollen nur sehen was dogen kosten.
|
||
sonst wissen die ja nicht wie viel es kosten wird."
|
||
Klein unter der Zahl: Die Dogen sind die Währung des
|
||
Hauses, der Preis ist die Auskunft dazu. Andersherum
|
||
wäre es ein Geldknopf mit Dogen als Beiwerk. */
|
||
if (b.cent) k.append(el('span', 'geben__preis', window.Spendenkarte.alsGeld(b.cent)));
|
||
k.href = b.ziel;
|
||
k.target = '_blank';
|
||
k.rel = 'noopener noreferrer';
|
||
k.addEventListener('click', () => nachgefragt(b.mille));
|
||
reihe.append(k);
|
||
}
|
||
const frei = $('geben-frei');
|
||
frei.href = spendenLage.paypal.ziel;
|
||
frei.hidden = false;
|
||
frei.addEventListener('click', () => nachgefragt(0));
|
||
}
|
||
}
|
||
|
||
/** Nach dem Tippen auf einen Betrag: die eine Frage — und das
|
||
* Feld für den eigenen Text.
|
||
*
|
||
* ==== WARUM DER TEXT ERST HIER KOMMT
|
||
*
|
||
* Er könnte auch vor dem Tippen dastehen. Dann schriebe ihn aber
|
||
* auch, wer danach gar nicht spendet — und die Leitung bekäme
|
||
* Texte ohne Spenden zu sehen. Hier steht er an der einzigen
|
||
* Stelle, an der er sicher gemeint ist. */
|
||
function nachgefragt(mille) {
|
||
gemeldeterBetrag = mille;
|
||
$('geben-danke').hidden = true;
|
||
$('geben-nach').hidden = false;
|
||
const wieviel = mille
|
||
? `${window.Spendenkarte.alsDogen(mille)} ${window.Spendenkarte.dogenWort(mille)}`
|
||
: '';
|
||
$('geben-frage').textContent = mille
|
||
? `Hat es geklappt? Dann sag es — ${wieviel}.`
|
||
: 'Hat es geklappt? Dann sag kurz, wie viele Dogen.';
|
||
$('geben-melden').textContent = mille ? `Ja, ${wieviel}` : 'Ja, melden';
|
||
/* Der eigene Name steht schon drin — wer ihn so will, muss
|
||
nichts tun. */
|
||
const nf = $('geben-name');
|
||
if (!nf.value && lage?.ich?.name) nf.value = lage.ich.name;
|
||
restZeigen();
|
||
nf.focus();
|
||
}
|
||
|
||
/** Wie viel noch übrig ist.
|
||
*
|
||
* DIE ZAHL STEHT ERST DA, WENN ES ENG WIRD. Ein Zähler, der von
|
||
* Anfang an mitläuft, macht aus einem Gruß eine Aufgabe. Ab
|
||
* dreißig übrigen Zeichen ist er eine Hilfe, vorher eine
|
||
* Aufsicht. */
|
||
function restZeigen() {
|
||
const feld = $('geben-text');
|
||
const rest = (feld.maxLength || 140) - feld.value.length;
|
||
const wort = $('geben-rest');
|
||
wort.textContent = rest <= 30 ? `noch ${rest}` : '';
|
||
wort.dataset.eng = rest <= 10 ? 'ja' : 'nein';
|
||
}
|
||
|
||
/** Was ich selbst gesammelt habe.
|
||
*
|
||
* Filipe: „ich will dass die den leuten auch als punkte
|
||
* hinzugefügt werden."
|
||
*
|
||
* NUR WENN ETWAS DA IST. Eine Null zu zeigen ist keine Auskunft,
|
||
* sondern eine Aufforderung — und die gehört nicht neben einen
|
||
* Spendenknopf. */
|
||
function standZeigen() {
|
||
const p = $('geben-stand');
|
||
if (!p) return;
|
||
const mille = spendenLage?.ich?.dogen_mille || 0;
|
||
p.textContent = '';
|
||
p.hidden = mille <= 0;
|
||
if (mille <= 0) return;
|
||
p.append(document.createTextNode('Du hast '));
|
||
p.append(window.Spendenkarte.dogenStueck(mille, spendenLage.zeichen));
|
||
}
|
||
|
||
async function spendeMelden() {
|
||
let mille = gemeldeterBetrag;
|
||
if (!mille) {
|
||
/* `frageNachText` GAB ES NIE. Nur diese Zeile nannte es; gesetzt
|
||
hat es niemand. Die Abfrage lief damit IMMER über den nackten
|
||
`prompt()` — ausgerechnet die, die am häufigsten vorkommt.
|
||
|
||
Der Hausdialog kann das längst besser: `zahl:` ist ein
|
||
Zahlenfeld mit eigener Fehlerzeile („Das ist keine gültige
|
||
Anzahl"). Es gibt `{ zahl }` zurück oder `false`. */
|
||
const antwort = await window.frageNach?.({
|
||
titel: 'Wie viele Dogen waren es?',
|
||
was: 'Trag ein, was in der Spende stand. Kommastellen gehen auch.',
|
||
zahl: 'Dogen',
|
||
zahlHinweis: 'zum Beispiel 0,5 oder 2,5',
|
||
ja: 'Melden', nein: 'Doch nicht',
|
||
});
|
||
if (!antwort || antwort === true) return;
|
||
mille = dogenInMille(antwort.zahl);
|
||
if (!mille) return;
|
||
}
|
||
try {
|
||
const a = await hole('/workspace/api/spenden/melden', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
dogen_mille: mille,
|
||
name: $('geben-name').value.trim(),
|
||
gruss: $('geben-text').value.trim(),
|
||
}),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging gerade nicht.'); return; }
|
||
$('geben-nach').hidden = true;
|
||
$('geben-text').value = '';
|
||
restZeigen();
|
||
const d = $('geben-danke');
|
||
d.hidden = false;
|
||
d.textContent = 'Danke! Sobald DogFather es bestätigt, steht dein Text im Bild.';
|
||
} catch { sagFehler('Das ging gerade nicht.'); }
|
||
}
|
||
|
||
/** „0,5", „0.5", „1", „2,5 Dogen" — alles dasselbe.
|
||
*
|
||
* ==== GERECHNET WIRD IN TAUSENDSTELN
|
||
*
|
||
* Eine Kommazahl ist der zuverlässigste Weg zu einem Wert, der um
|
||
* ein Tausendstel danebenliegt — und bei etwas, das Leute
|
||
* sammeln, fällt das irgendwann dem auf, der am meisten hat.
|
||
*
|
||
* UND HIER STEHT KEIN KURS. Diese Datei weiß nicht, was ein Doge
|
||
* in Geld wert ist, und soll es nicht wissen: Der Kurs steht
|
||
* einmal auf dem Server. Was hier hereingeht und hinausgeht, sind
|
||
* Dogen. */
|
||
function dogenInMille(wort) {
|
||
const t = String(wort || '').replace(/[^0-9,.]/g, '').replace(',', '.');
|
||
if (!t) return 0;
|
||
const zahl = Number(t);
|
||
if (!Number.isFinite(zahl) || zahl <= 0) return 0;
|
||
return Math.round(zahl * 1000);
|
||
}
|
||
|
||
/** Dogen in Geld — oder nichts.
|
||
*
|
||
* ==== DIE ERLAUBNIS STECKT IN DEN DATEN, NICHT IN EINEM `if`
|
||
*
|
||
* Der Kurs kommt nur für die Leitung mit (`kurs_cent_je_doge`).
|
||
* Ein Zuschauer hat ihn nicht und kann deshalb GAR KEINEN Preis
|
||
* ausrechnen -- auch nicht, wenn eine spätere Zeile es versuchte.
|
||
* Eine Abfrage „darf der das sehen?" in der Oberfläche wäre eine
|
||
* Regel, die man vergessen kann; eine fehlende Zahl ist eine, die
|
||
* man nicht vergessen kann.
|
||
*
|
||
* Die Kaufknöpfe sind die Ausnahme: Dort schickt der Server den
|
||
* Betrag ausdrücklich mit, für jeden. */
|
||
function alsPreis(mille) {
|
||
const kurs = spendenLage?.kurs_cent_je_doge;
|
||
if (!kurs) return '';
|
||
return window.Spendenkarte.alsGeld(Math.round((Number(mille) || 0) * kurs / 1000));
|
||
}
|
||
|
||
/** Tausendstel zurück in das, was im Feld steht. */
|
||
function milleAlsWort(mille) {
|
||
return String((Math.round(Number(mille) || 0) / 1000)).replace('.', ',');
|
||
}
|
||
|
||
/* =================================================================
|
||
LOS
|
||
================================================================= */
|
||
/** Aus der Sendung -- und das wird auch gesagt.
|
||
*
|
||
* Drei Dinge müssen zusammen passieren, sonst bleibt ein Rest:
|
||
* Der Takt hört auf (sonst klopft er alle 30 Sekunden gegen eine
|
||
* geschlossene Tür), die Leitungen gehen weg (sonst stünde die
|
||
* eigene Kamera weiter im Bild der anderen), und das Video hält an
|
||
* (sonst läuft die Sendung hinter der Meldung weiter). */
|
||
function rausgenommen(wort) {
|
||
if (document.body.dataset.raus === 'ja') return;
|
||
document.body.dataset.raus = 'ja';
|
||
clearInterval(dabeiUhr);
|
||
try { draehteWeg(); } catch { /* es gab vielleicht keine */ }
|
||
try { if (spieler && spielerBereit) spieler.pauseVideo(); } catch { /* egal */ }
|
||
try { if (strom) strom.close(); } catch { /* war schon zu */ }
|
||
|
||
/* ==== DER VORHANG GEHT VON SELBST WIEDER AUF (30.09.2026) =====
|
||
|
||
Bis hierher blieb er stehen, bis jemand die Seite neu lud.
|
||
Nimmt die Moderation eine Maßnahme zurück — und das kommt vor,
|
||
ein Klick daneben ist schnell passiert —, saß die Person
|
||
weiter vor „Du bist aus der Sendung", ohne dass ihr jemand
|
||
sagen konnte warum. Wir haben sie bisher gebeten, neu zu
|
||
laden.
|
||
|
||
NUR DIE EINE FRAGE, NICHT DER GANZE STAND. Der Strom ist
|
||
oben mit Absicht geschlossen: Wer draußen ist, soll die
|
||
Sendung nicht weiterverfolgen. `/api/reaktion` brächte Video,
|
||
Titel und Stand mit — genau das, was ihr genommen werden
|
||
soll. `POST /dabei` beantwortet dagegen nur „bin ich wieder
|
||
dabei?" und verrät dabei nichts.
|
||
|
||
UND NEU GELADEN WIRD NUR BEI 200. Das ist der Unterschied
|
||
zwischen einer Lösung und einer Schleife: Schließt der Saal,
|
||
während jemand draußen ist, kommt 409. Lüde die Seite darauf
|
||
neu, stünde die Maßnahme danach immer noch, der Vorhang käme
|
||
wieder — und das Ganze von vorn.
|
||
|
||
Zwanzig Sekunden: Eine Maßnahme ist kein Anruf. Wer
|
||
zurückgeholt wird, ist eine halbe Minute später wieder da,
|
||
und das reicht. */
|
||
clearInterval(vorhangUhr);
|
||
vorhangUhr = setInterval(async () => {
|
||
try {
|
||
const a = await hole('/workspace/api/reaktion/dabei', { method: 'POST' });
|
||
if (a.ok) { clearInterval(vorhangUhr); location.reload(); }
|
||
} catch { /* kein Netz -- der nächste Versuch kommt */ }
|
||
}, 20_000);
|
||
|
||
const kasten = el('div', 'raus');
|
||
kasten.setAttribute('role', 'alert');
|
||
kasten.append(el('h2', 'raus__titel',
|
||
wort === 'treff_massnahme' ? 'Du bist gerade gesperrt' : 'Du bist aus der Sendung'));
|
||
kasten.append(el('p', 'raus__text',
|
||
wort === 'treff_massnahme'
|
||
? 'Im Treff läuft eine Maßnahme gegen dich. Solange sie läuft, bist du '
|
||
+ 'bei der Reaction nicht dabei. Was los ist, steht im Treff unter „Meine Maßnahmen".'
|
||
: 'Die Moderation hat dich aus dieser Sendung genommen. Das gilt nur für heute — '
|
||
+ 'bei der nächsten Reaction bist du wieder dabei.'));
|
||
document.body.append(kasten);
|
||
}
|
||
|
||
/* ==== WER ZULETZT ANTWORTET, HAT NICHT RECHT (30.09.2026) =======
|
||
|
||
Eine Abfrage ist Sekundenbruchteile unterwegs. Kommt in dieser
|
||
Zeit ein Rundruf an, ist SEIN Stand der neuere — und trotzdem
|
||
hat die verspätete Antwort ihn überschrieben, weil sie später
|
||
ankam.
|
||
|
||
Gemessen: Die Regie schaltet die zwei Eckenbilder ein, der
|
||
Rundruf zeichnet sie, und eine Sekunde später waren sie wieder
|
||
weg — eine beim Seitenaufbau gestartete Abfrage war
|
||
zurückgekommen und hatte den Stand von vorher gesetzt. Eine
|
||
feste Wartezeit in der Messung hatte das monatelang zugedeckt;
|
||
sie fiel erst auf, als ich sie durch eine echte Bedingung
|
||
ersetzte.
|
||
|
||
Die Folgenummer zählt jeden angekommenen Rundruf. Eine SANFTE
|
||
Abfrage merkt sie sich, bevor sie losgeht, und wirft ihr
|
||
Ergebnis weg, wenn inzwischen etwas Neueres da war. */
|
||
let standFolge = 0;
|
||
|
||
/** Den ganzen Stand holen.
|
||
*
|
||
* @param sanft `true`: verwirft das Ergebnis, wenn währenddessen
|
||
* ein Rundruf angekommen ist. Für Abfragen, die die
|
||
* Seite von sich aus macht (nach dem Verbindungs-
|
||
* aufbau, im geschlossenen Saal).
|
||
*
|
||
* Ohne Angabe gewinnt die Abfrage — sie bringt
|
||
* Dinge mit, die kein Rundruf trägt (etwa die
|
||
* Verwaltungsliste der Regie). Dort wäre der
|
||
* Vorrang des Rundrufs ein Verlust. */
|
||
async function standHolen(sanft) {
|
||
const beim = standFolge;
|
||
/* GLEICH MITGEMESSEN: wie lange ein Hin und Zurueck dauert.
|
||
Daraus schaetzt der Gleichlauf die Laufzeit einer Meldung zu
|
||
mir. Ein eigener Weg nur zum Messen waere ein zweiter Weg,
|
||
der irgendwann eine andere Antwort gibt als dieser. */
|
||
const t0 = performance.now();
|
||
const a = await hole('/workspace/api/reaktion');
|
||
umlaufMerken(performance.now() - t0);
|
||
if (!a.ok) { sagFehler('Die Reaction ist gerade nicht erreichbar.'); return; }
|
||
const neu = await a.json();
|
||
if (sanft && beim !== standFolge) return;
|
||
/* DER ANKER GEHT SOFORT LOS -- vor dem Zeichnen, vor allem
|
||
anderen. Er merkt sich mit `performance.now()`, wann diese
|
||
Zahl ankam; jede Sekunde, die zwischen Ankunft und Auswertung
|
||
liegt, waere sonst ein Rueckstand, den niemand mehr sieht. */
|
||
ankerSetzen(neu);
|
||
lage = neu;
|
||
zeichnen();
|
||
/* Und gleich hinterher: Steht der Spieler schon, wird jetzt
|
||
nachgefuehrt. Ohne das wartete ein frisch geladener Stand bis
|
||
zum naechsten Takt. */
|
||
nachfuehren();
|
||
}
|
||
|
||
async function los() {
|
||
/* DIE PEGEL LAUFEN FUER JEDEN, DER SENDET -- und nicht nur fuer
|
||
das Pult. Vorher wurden sie in `pultAufbauen()` gestartet, und
|
||
das Pult hat nur, wer die Sendung fuehrt: Ein Gast haette
|
||
seinen Pegel nie gesehen. */
|
||
setInterval(pegelTakt, 90); // 90 ms -- darunter sieht das Auge nichts mehr
|
||
setInterval(pegelNachziehen, 2500);
|
||
|
||
/* Spenden melden */
|
||
$('geben-melden').addEventListener('click', spendeMelden);
|
||
$('geben-abbruch').addEventListener('click', () => { $('geben-nach').hidden = true; });
|
||
$('geben-text').addEventListener('input', restZeigen);
|
||
|
||
/* Kamera und Mikro -- fuer jeden, der sendet */
|
||
$('s-kamera').addEventListener('click',
|
||
() => sendenSchalten('kamera', !lage.ich.kamera_aus));
|
||
$('s-mikro').addEventListener('click',
|
||
() => sendenSchalten('mikro', !lage.ich.mikro_aus));
|
||
$('s-teilen').addEventListener('click', bildschirmSchalten);
|
||
|
||
/* Die Chat-Stufe -- nur fuer die zwei */
|
||
$('chat-meldungen')?.addEventListener('click', meldungenZeigen);
|
||
|
||
for (const k of $('chat-stufen').children) {
|
||
k.addEventListener('click', async () => {
|
||
const a = await hole('/workspace/api/reaktion/chatmodus', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ modus: k.dataset.modus }),
|
||
});
|
||
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
|
||
lage = { ...(await a.json()) };
|
||
zeichnen();
|
||
});
|
||
}
|
||
|
||
/* Der Chat: absenden */
|
||
$('chat-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const feld = $('chat-feld');
|
||
const text = feld.value.trim();
|
||
if (!text) return;
|
||
feld.value = '';
|
||
const a = await hole('/workspace/api/reaktion/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
});
|
||
if (!a.ok) {
|
||
feld.value = text;
|
||
melde(await a.json().catch(() => null), 'Das ging nicht.');
|
||
}
|
||
});
|
||
|
||
$('ton-an').addEventListener('click', tonAn);
|
||
|
||
/* Der Knopf steht nur da, solange er gebraucht wird -- und nur,
|
||
wenn überhaupt jemand sendet. */
|
||
/* DER KNOPF HÄNGT AN DER SENDUNG, NICHT AN DEN KAMERAS.
|
||
|
||
Vorher fragte er „gibt es fremde Kamerafenster?". Läuft eine
|
||
Sendung, in der noch niemand die Kamera an hat, wäre der Ton
|
||
des Videos stumm geblieben -- ohne dass irgendwo ein Knopf
|
||
gestanden hätte. */
|
||
setInterval(() => {
|
||
const knopf = $('ton-an');
|
||
if (!knopf) return;
|
||
knopf.hidden = tonFrei || !lage || lage.stand !== 'live';
|
||
}, 1200);
|
||
|
||
/* KEINE LADE MEHR AM HANDY (28.09.2026). Der Chat steht dort
|
||
jetzt einfach unter dem Video -- siehe reaktion.css. Ein
|
||
Zustand weniger ist besser als ein Zustand, der funktioniert. */
|
||
|
||
await standHolen();
|
||
await adressenHolen();
|
||
stromAuf();
|
||
chatLaden();
|
||
spendenHolen();
|
||
anschliessen();
|
||
|
||
/* Wer die Seite verlässt, ist weg -- sofort und nicht erst nach
|
||
zwei Minuten. `pagehide` und nicht `unload`: Auf dem Handy wird
|
||
`unload` oft gar nicht mehr ausgelöst. */
|
||
window.addEventListener('pagehide', () => {
|
||
try {
|
||
navigator.sendBeacon('/workspace/api/reaktion/dabei-weg');
|
||
} catch { /* dann räumt die Zwei-Minuten-Grenze auf */ }
|
||
draehteWeg();
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', los, { once: true });
|
||
} else {
|
||
los();
|
||
}
|
||
})();
|