Files
dogfather-universe/workspace/assets/js/reaktion.js
T
DogFatherGit 5d89d108f9 Die Reaction: zusammen schauen, live, mit Kamera und Chat
Filipes Kurznotiz vom 28.09.2026, von links nach rechts:

    Kachel sichtbar -> geschlossen -> Vorbereitung -> Wartebereich/
    Chat -> Countdown -> LIVE -> Reaction + Gaeste + Chat + PayPal
    -> Ende

DREI STAENDE, NICHT SIEBEN

„Wartebereich" und „Countdown" sind keine eigenen Zustaende, sondern
das, was „Vorbereitung" auf dem Bildschirm TUT. Drei Staende, die
sich gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
vier ueberlappen, sind es nicht.

DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER

Naheliegend waere: Der Host spielt ab, alle sehen seinen Bildschirm.
Das waere aus zwei Gruenden falsch. Rechtlich ist ein
weitergesendetes YouTube-Video eine oeffentliche Wiedergabe -- genau
die Sache, fuer die Kanaele gesperrt werden. Und technisch kostet es
Bandbreite und Qualitaet.

Jeder Zuschauer laedt das Video deshalb SELBST. Uebertragen wird nur
der Spielstand: Kennung, laeuft/pausiert, Sekunde. Das sind ein paar
Byte, jeder sieht es in voller Qualitaet, und alle sind auf derselben
Sekunde. Nachgefuehrt wird erst ab anderthalb Sekunden Abweichung --
ein Player, dem man jede Sekunde eine neue Position gibt, ruckelt
sichtbar.

DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH

Ueber denselben Weg wie die Anrufe im Haus (seit 18.09.), nur mit
mehr Empfaengern. Das hat eine Grenze, und sie ist gerechnet, nicht
geraten: Bei 360p und rund 350 kbit/s sind zwoelf Zuschauer etwa
4 Mbit/s Upload beim Host. Darueber schaltet die Sendung von selbst
auf Ton um -- wer keine Kamera mehr bekommt, hoert alles, sieht das
Video und kann schreiben. Ehrlicher als eine Verbindung, die stockt,
und sichtbar im Regiepult.

Heute sind es elf Menschen im ganzen Haus (gemessen: 1 admin, 1 hand,
1 linke, 4 modi, 4 gast). Die Grenze ist weit weg -- sie steht
trotzdem drin, weil sie sonst erst auffaellt, wenn es zu spaet ist.

DIE SEITE IST ANDERS GEBAUT ALS JEDE ANDERE IM HAUS

Ueberall sonst: Kacheln, Karten, Listen -- man liest, entscheidet,
geht wieder. Hier sitzt man. Eine Stunde, mit anderen, auf EINE
Sache schauend. Deshalb kein Raster, sondern ein SAAL: grosse Flaeche
fuer das Video, Kamerabilder als schwebende Fenster darueber, der
Chat als Schiene daneben. Die Seite scrollt nicht -- ein Video, das
beim Tippen im Chat nach oben rutscht, ist der schnellste Weg, dass
jemand aufhoert zu schreiben.

Fuer den Host ein REGIEPULT: vier senkrechte Regler nebeneinander wie
an einem Mischpult, darueber die Sendung, daneben Gaeste und
Anordnung, unten drei grosse Knoepfe. Es SCHIEBT den Saal, es deckt
ihn nicht zu.

Die Kachel traegt ihren Zustand als Farbe: grau geschlossen,
bernstein in Vorbereitung, rot auf Sendung. Keine Ton-Nummer -- der
Farbraum ist bei 46 voll, und sie braucht auch keine.

PAYPAL: EINE QUELLE

Der Knopf nimmt den Weg, der auf der Unterstuetzen-Seite hinterlegt
ist -- derselbe Eintrag, dieselbe Pflege. Ist dort nichts eingetragen
oder steht er auf unsichtbar, erscheint hier kein Knopf. Eine
geratene Adresse ist an dieser Stelle die gefaehrlichste aller
Abkuerzungen.

=======================================================================
ACHT FEHLER, DIE OHNE MESSUNG LIVE GEGANGEN WAEREN
=======================================================================

1. `data-live` WAR SCHON VERGEBEN. Die Draussen-Kachel bekommt es,
   sobald Filipe auf Twitch sendet. Meine Regel haette ihr waehrend
   jedes Streams die Farbe genommen -- genau dann, wenn sie wichtig
   ist. Heisst jetzt `data-sendung`, und pruef-reaktion haelt beides
   auseinander.

2. DIE INHALTSRICHTLINIE HAETTE YOUTUBE LAUTLOS GESPERRT. Die Datei
   warnt an genau dieser Stelle selbst davor: Am 27.08.2026 hat
   `frame-src 'none'` den Musik-Knopf stillgelegt -- der Knopf
   reagierte, das Feld ging auf, und wo die Player sein sollten,
   blieb es leer. Hier waere das Ergebnis eine schwarze Leinwand vor
   Publikum gewesen. youtube-nocookie.com fuer den Rahmen (setzt keine
   Werbekennungen), www.youtube.com fuer die Einbett-API,
   i.ytimg.com fuer die Vorschaubilder.

3. KAMERA UND MIKROFON WAREN GESPERRT. Dieselbe Falle, vor der
   index.js selbst warnt -- und die am 18.09. schon einmal zugeschlagen
   hat. Die Ausnahme ist jetzt eine benannte MENGE statt eines zweiten
   Sonderfalls, und pruef-kamera-richtlinie.mjs haelt sie GEGEN DEN
   QUELLTEXT: Welche Seite laedt ein Skript, das getUserMedia
   aufruft? Genau die muss drinstehen -- und keine andere. Eine
   Liste, die abgeleitet wird, kann nicht veralten.

4. ZWEI ANRUFE AN DIESELBE PERSON. Zwischen `await kameraHolen()` und
   dem Anlegen der Verbindung laeuft alles andere weiter; jeder Takt
   sagte wieder „den kenne ich noch nicht". Der Empfaenger antwortete
   auf beide Angebote, und die zweite Antwort traf eine Verbindung,
   die laengst stand.

5. DAS ANGEBOT GING HINAUS, BEVOR DER EMPFAENGER ZUHOEREN KONNTE.
   Gemessen:

       [spur] an [3] reaktion_signal | offen: [2,1]
       ...
       [spur] Strom auf fuer 3 Lenny

   Die Anmeldung ist ein gewoehnlicher Abruf und sofort durch, der
   Ereignisstrom eine stehende Verbindung. Der Host erfaehrt vom
   Neuankoemmling also zuverlaessig, BEVOR der zuhoeren kann.
   Die Richtung ist jetzt umgedreht: Wer bereit ist, BITTET um den
   Anruf -- er ist der Einzige, der das sicher weiss. Dazu ein
   eigener, schneller Takt (2,5 s) und eine Ruecknahme, wenn ein
   Angebot bei niemandem ankommt.

6. EIN VIDEO MIT TON STARTET NICHT VON ALLEIN. `videoWidth` war 640,
   das Bild kam also an -- und das Fenster blieb schwarz. Kein
   Fehler, keine Meldung, es passiert einfach nichts. Die Kameras
   starten jetzt stumm (stumm darf losgehen), ein Knopf schaltet den
   Ton frei, und die erste Beruehrung der Seite tut es ohnehin.

7. DIE LADE AM HANDY GING NICHT AUF. Gemessen: ein 390x775 grosser
   Saal mit 219 px Video und 556 px Leere darunter. Statt den Knopf
   zu reparieren, ist die Lade weg -- unter Kopfleiste und Video
   bleiben auf einem Telefon rund 550 px, das ist mehr Chat, als eine
   Lade je zeigen wuerde. Ein Zustand weniger ist besser als ein
   Zustand, der funktioniert.

8. `sendBeacon` KANN NUR POST. Beim Schliessen des Fensters wird ein
   gewoehnlicher Abruf abgebrochen; mein DELETE waere nie angekommen,
   und jeder haette zwei Minuten lang als anwesend gegolten.

Dazu drei Funde der Hauspruefungen, alle von mir verursacht:
17 Schriftgroessen unter der Lesbarkeitsgrenze von 11,5 px, elf
Maschinenworte ohne deutschen Satz, und ein Aufbewahrungseintrag ohne
Rechtsgrundlage.

=======================================================================

GEMESSEN

pruef-reaktion            74 Punkte, 0 Fehler (11 Abschnitte)
pruef-kamera-richtlinie   10 Punkte, 0 Fehler (neu, abgeleitet)
mess-reaktion             beide Kameras kommen an, 640 px, laufen --
                          beim Zuschauer UND beim Host. Diese Messung
                          hat einen Rueckgabewert: Alles andere kann
                          gruen sein, und trotzdem sitzt jeder vor
                          einem schwarzen Rechteck.
pruef-handy               180 (vorher 177), pruef-notizen 79,
pruef-aufbewahrung        45, pruef-meldungen 8, pruef-css-klassen 33,
pruef-struktur            35, pruef-crew-adresse 161,
pruef-haus-trennung       100, pruef-start-ansicht 160 -- alle 0 Fehler.
2026-09-28 01:52:22 +02:00

1124 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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;
};
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
================================================================= */
function zeichnen() {
if (!lage) return;
saal.dataset.stand = lage.stand;
document.body.dataset.stand = lage.stand;
meineId = lage.ich.id;
binSender = lage.ich.host || lage.ich.gast;
/* ---- 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}")` : '';
/* ---- Live ---- */
const kino = $('teil-live');
kino.dataset.layout = lage.layout || 'kino';
kamerasZeichnen();
/* ---- Das Pult ---- */
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';
fenster.dataset.stumm = s.stumm ? 'ja' : 'nein';
const draht = drähte.get(s.id);
const v = fenster.querySelector('video');
if (s.id === meineId && meinStrom && v.srcObject !== meinStrom) {
v.srcObject = meinStrom;
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();
}
}
/* ---- 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 */ });
}
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
anderthalb Sekunden Unterschied wird gesprungen -- darunter
merkt es niemand, darüber merkt es jeder.
================================================================= */
let spieler = null;
let spielerBereit = false;
let letztesVideo = '';
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,
/* 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;
if (lage.ich.host) hostTakt();
else folgen(lage);
},
onStateChange: () => { 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);
}
async function hostMelden() {
if (!spielerBereit || !lage || lage.stand !== 'live') return;
try {
const laeuft = spieler.getPlayerState() === 1;
const sekunde = spieler.getCurrentTime();
await hole('/workspace/api/reaktion/video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ laeuft, sekunde }),
});
} catch { /* der nächste Takt versucht es wieder */ }
}
/** Alle anderen folgen. */
function folgen(stand) {
if (!spielerBereit || !stand) return;
if (stand.video && stand.video !== letztesVideo) {
letztesVideo = stand.video;
spieler.loadVideoById(stand.video, stand.sekunde || 0);
return;
}
/* Die Laufzeit der Meldung wird dazugerechnet. Ohne das wäre
jeder genau so weit hinten, wie sein Netz langsam ist. */
const unterwegs = stand.gesendet ? Math.max(0, (Date.now() - stand.gesendet) / 1000) : 0;
const soll = (stand.sekunde || 0) + (stand.laeuft ? unterwegs : 0);
const ist = spieler.getCurrentTime();
if (Math.abs(ist - soll) > 1.5) spieler.seekTo(soll, true);
if (stand.laeuft && spieler.getPlayerState() !== 1) spieler.playVideo();
if (!stand.laeuft && spieler.getPlayerState() === 1) spieler.pauseVideo();
}
/* =================================================================
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;
}
async function kameraHolen() {
if (meinStrom) return meinStrom;
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 });
d = { pc, strom: null };
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) {
for (const spur of meinStrom.getTracks()) pc.addTrack(spur, 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;
}
}
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);
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 {
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;
const alt = drähte.get(von);
if (alt) {
/* Eine halbfertige Verbindung wegwerfen: Der andere hat
gerade gesagt, dass er nichts bekommen hat. */
try { alt.pc.close(); } catch { /* egal */ }
drähte.delete(von);
}
anrufen(von);
return;
}
if (daten.typ === 'angebot') {
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);
}
for (const id of sollen) {
if (drähte.has(id) || ruftGerade.has(id)) continue;
if (ichRufeAn(id)) anrufen(id);
else bitten(id);
}
for (const [id, d] of [...drähte]) {
if (!sollen.has(id)) {
try { d.pc.close(); } catch { /* egal */ }
drähte.delete(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' });
}
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
================================================================= */
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 */ }
}
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));
/* 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);
}
li.append(kopf);
li.append(el('div', null, b.text));
return li;
}
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;
}
/* =================================================================
DAS REGIEPULT
================================================================= */
const REGLER = [
{ schluessel: 'mikro', wort: 'Mein Mikro' },
{ schluessel: 'gast1', wort: 'Gast 1' },
{ schluessel: 'gast2', wort: 'Gast 2' },
{ schluessel: 'video', wort: 'YouTube' },
];
const staende = { mikro: 100, gast1: 100, gast2: 100, video: 100 };
function pultAufbauen() {
/* ---- Die Regler ---- */
const reihe = $('regler');
reihe.textContent = '';
for (const r of REGLER) {
const kasten = el('div', 'regler');
kasten.dataset.was = r.schluessel;
kasten.append(el('span', 'regler__wort', r.wort));
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);
kasten.append(zahl);
const stumm = el('button', 'regler__stumm', 'stumm');
stumm.type = 'button';
stumm.addEventListener('click', () => {
const jetztStumm = kasten.dataset.stumm !== 'ja';
kasten.dataset.stumm = jetztStumm ? 'ja' : 'nein';
tonNachziehen();
/* Beim eigenen Mikro gilt das für ALLE -- die Spur wird
abgeschaltet, und dann kommt beim Empfänger nichts mehr an.
Bei den Gästen gilt es nur hier; für alle stummschalten
geht über den Knopf an der Gastkarte. */
if (r.schluessel === 'mikro' && meinStrom) {
for (const s of meinStrom.getAudioTracks()) s.enabled = !jetztStumm;
}
});
kasten.append(stumm);
reihe.append(kasten);
}
const hinweis = el('p', 'pult__klein',
'Die Regler gelten für das, was DU hörst. Nur „Mein Mikro" und '
+ 'der Stummschalter an einem Gast gelten für alle.');
reihe.parentElement.append(hinweis);
/* ---- Anordnung ---- */
const layouts = [
['kino', 'Video groß'],
['gleich', 'Gleich groß'],
['kamera', 'Kameras groß'],
];
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', async () => {
const a = await hole('/workspace/api/reaktion/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: schluessel }),
});
if (!a.ok) melde(await a.json().catch(() => null), 'Die Anordnung ging nicht.');
});
lreihe.append(k);
}
/* ---- Die Knöpfe ---- */
$('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?',
text: 'Der Saal geht zu, der Live-Chat dieser Sendung wird abgeräumt '
+ 'und die Gäste werden entlassen.',
knopf: 'Beenden',
})
: confirm('Sendung beenden?');
if (ja) standSetzen('zu');
});
$('p-holen').addEventListener('click', gastHolen);
$('pult-griff').addEventListener('click', () => {
const pult = $('pult');
const auf = pult.dataset.auf !== 'ja';
pult.dataset.auf = auf ? 'ja' : 'nein';
$('pult-griff').setAttribute('aria-expanded', String(auf));
});
}
function tonNachziehen() {
/* YouTube */
if (spielerBereit) {
const aus = $('regler').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 = $('regler').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);
});
}
function pultFuellen() {
$('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 || '';
if (lage.beginnt_am && document.activeElement !== $('p-beginn')) {
const d = new Date(lage.beginnt_am);
if (!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())}`;
}
}
/* 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';
for (const k of $('layout-reihe').children) {
k.setAttribute('aria-pressed', String(k.dataset.layout === (lage.layout || 'kino')));
}
/* 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;
}
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');
strom.addEventListener('open', () => { stromOffen = true; });
strom.addEventListener('reaktion', (e) => {
try {
const d = JSON.parse(e.data);
if (d.was === 'video') { folgen(d); return; }
if (d.was === 'layout') { $('teil-live').dataset.layout = d.layout; 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. */
lage = { ...lage, ...d.lage, ich: lage.ich, zuschauer: lage.zuschauer };
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(); }
}
} 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 */ }
});
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;
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();
return;
}
await stromAbwarten();
const melden = async () => {
try {
const a = await hole('/workspace/api/reaktion/dabei', { method: 'POST' });
if (a.ok) {
const neu = await a.json();
lage = neu;
zeichnen();
draehteNachziehen();
}
} 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.
================================================================= */
async function dankeHolen() {
try {
const a = await hole('/workspace/api/unterstuetzung');
if (!a.ok) return;
const d = await a.json();
const wege = d.wege || d.liste || [];
const pp = wege.find((w) => w.schluessel === 'paypal' && w.sichtbar && w.ziel);
if (!pp) return;
const knopf = $('danke');
knopf.href = pp.ziel;
knopf.querySelector('span').textContent = pp.knopf || 'Dogi was ausgeben';
knopf.hidden = false;
} catch { /* ohne Knopf ist die Seite vollständig */ }
}
/* =================================================================
LOS
================================================================= */
async function standHolen() {
const a = await hole('/workspace/api/reaktion');
if (!a.ok) { sagFehler('Die Reaction ist gerade nicht erreichbar.'); return; }
lage = await a.json();
zeichnen();
}
async function los() {
/* 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. */
setInterval(() => {
const knopf = $('ton-an');
if (!knopf) return;
const stumme = document.querySelectorAll('#kameras video[data-fremd="ja"]').length;
knopf.hidden = tonFrei || !stumme || !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();
dankeHolen();
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();
}
})();