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.
1124 lines
42 KiB
JavaScript
1124 lines
42 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;
|
||
};
|
||
|
||
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();
|
||
}
|
||
})();
|