Kamera und Chat -- die zwei fehlenden Host-Steuerungen

Filipes Notiz nennt acht: "Host-Steuerung fuer Kamera, Mikrofon,
Video, Gaeste, Lautstaerke, Chat, Layout und Start/Ende."
Nachgezaehlt war sechsmal etwas da und zweimal nichts.

KAMERA

Es gab keinen Weg, das eigene Bild abzuschalten. Wer kurz aufstehen,
trinken oder etwas holen wollte, musste die Sendung verlassen oder
sich dabei filmen lassen.

Jetzt liegt eine SENDERLEISTE ueber dem Bild -- Kamera, Mikro und ein
Pegel. Sie gehoert jedem, der sendet, Host wie Gast: Ein Gast, der
sein eigenes Bild nicht abschalten kann, muesste den Host darum
bitten, und das ist keine Bedienung, sondern eine Bitte.

DAS BILD WIRD AM GERAET ABGESCHALTET (`track.enabled = false`), nicht
am Server: Die Verbindung bleibt stehen, der Ton laeuft weiter, und
beim Wiedereinschalten ist das Bild sofort da. Der Server erfaehrt es
nur, damit bei den ANDEREN "Kamera aus" im Fenster steht. Ohne diese
Beschriftung sind ein abgeschaltetes und ein kaputtes Bild dasselbe
schwarze Rechteck -- und dann fragt jemand im Chat, ob die Technik
hakt.

Erst das Geraet, dann die Ansage. Andersherum stuende bei allen
"Kamera aus", waehrend noch ein Bild fliesst; man glaubte sich
unsichtbar. Geht die Ansage nicht durch, wird das Geraet
zurueckgesetzt -- ein Knopf, der halb wirkt, ist schlimmer als einer,
der gar nicht wirkt.

Das Mikro laeuft denselben Weg. Erst wollte ich es rein oertlich
lassen; das waere dieselbe stille Falle gewesen: Wer sich selbst
stummschaltet und trotzdem redet, saehe bei allen anderen ein ganz
normales Fenster.

CHAT

Es gab Moderation -- Beitraege wegnehmen -- aber keine Steuerung des
Chats selbst. Einen Beitrag zu loeschen, nachdem er stand, ist etwas
anderes, als ihn gar nicht erst zuzulassen.

Drei Stufen: OFFEN, TEAM (wer moderiert, darf -- wer aufraeumen soll,
muss dabei reden koennen) und ZU (nur die zwei, die fuehren). Zwei
Stufen waeren zu wenig: "zu" ist in einer Sendung fast immer zu viel,
dann sitzen alle vor einem stummen Fenster. Die mittlere ist die, die
man wirklich braucht.

Die Schalter stehen im Kopf der Chatschiene, nicht im Regiepult: Man
moderiert, wo man liest. Ein Umweg ueber ein Register waere in dem
Moment, in dem es laut wird, genau ein Umweg zu viel.

EIN GESPERRTES FELD SAGT, WARUM. "Gerade schreibt nur das Team" statt
eines Feldes, das sich nicht beschreiben laesst und schweigt --
sonst schreibt jemand in den Hauschat, dass die Reaction hakt. Und
die Stufe gilt AM SERVER: Ein ausgegrautes Feld haelt niemanden auf,
der die Schnittstelle kennt. Beide Antworten kommen aus derselben
Funktion; zwei Rechnungen waeren zwei Gelegenheiten, dass ein Feld da
ist und mit 403 antwortet.

EIN FUND NEBENBEI: "MEIN MIKRO" WAR EIN PLACEBO

Gemessen: `staende.mikro` wird nirgends gelesen. Der Schieber liess
sich bewegen, die Zahl daneben aenderte sich -- und es passierte
nichts. Das ist schlimmer als ein fehlender Regler: Man glaubt, man
haette leiser gestellt.

Technisch ist das auch richtig. Die eigene Lautstaerke laesst sich
nicht am Regler aendern; man muesste den Ton umrechnen und die Spur
in jeder Verbindung austauschen. Was man beim eigenen Mikro braucht,
ist AN oder AUS -- und ein Pegel, der zeigt, dass es ankommt. Genau
das steht jetzt dort, als vierte Spalte im Pult, mit demselben
Zustand wie die Senderleiste. Die drei anderen Regler bleiben Regler:
Sie steuern, was ICH hoere, und das geht am Empfaenger.

UND EINER IN MEINER EIGENEN ARBEIT

`kasten.append(el("div","pegel")).append(el("i"))` -- `Node.append()`
gibt `undefined` zurueck, nicht das angehaengte Element. Ein
TypeError beim Aufbau des Pults, den `node --check` nicht sieht.
Beim Verkuerzen nicht nachgesehen, was die Methode zurueckgibt.

Dazu: Die Pegel-Takte liefen in `pultAufbauen()`. Das Pult hat nur,
wer die Sendung fuehrt -- ein Gast haette seinen Pegel nie gesehen,
und genau er braucht ihn am dringendsten.

GEMESSEN

  mess-reaktion   Der Host schaltet ab, und bei der Zuschauerin steht
                  "Kamera aus" bei DogFather, Bild verdeckt.
                  Stufe "Team": Feld gesperrt mit Grund, und der
                  Server lehnt denselben Versuch mit 403 ab.
  pruef-reaktion  154 Punkte, 0 Fehler (vorher 132), neuer Abschnitt
                  13 mit 22 Punkten und Gegenprobe (es geht auch
                  wieder auf -- eine Sperre, die man nicht loesen
                  kann, ist keine Stufe, sondern ein Ende)
  dazu gruen      handy 180, css-klassen 33, struktur 35,
                  tippziele 11, aufbewahrung 45, meldungen 8

Der neue Abschnitt baut sich seine Buehne selbst. Abschnitt 9 beendet
die Sendung; sich auf den Stand eines frueheren Abschnitts zu
verlassen ist die Kopplung, die spaeter jemand aus Versehen
zerreisst.

SCHEMA: drei Spalten (reaktion_dabei.kamera_aus, .mikro_aus,
reaktion.chat_modus), alle per ALTER TABLE. Auf einer Kopie der
echten Datenbank durchgespielt: 20 Personen, keine Tabelle verliert
eine Spalte.
This commit is contained in:
2026-09-28 08:16:49 +02:00
parent d79f6faa23
commit 28b492527e
50 changed files with 1415 additions and 698 deletions
+202 -31
View File
@@ -178,6 +178,8 @@
const kino = $('teil-live');
kino.dataset.layout = lage.layout || 'kino';
kamerasZeichnen();
senderleisteZeichnen();
chatStufeZeichnen();
/* ---- Das Pult ---- */
const pult = $('pult');
@@ -228,7 +230,21 @@
behaelter.append(fenster);
}
fenster.dataset.host = s.host ? 'ja' : 'nein';
fenster.dataset.stumm = s.stumm ? 'ja' : 'nein';
/* EIN ABGESCHALTETES BILD IST NICHT DASSELBE WIE EIN KAPUTTES.
Ohne diese Beschriftung sind beide ein schwarzes Rechteck,
und man fragt den anderen, ob bei ihm etwas klemmt. */
fenster.dataset.kamera = s.kamera_aus ? 'aus' : 'an';
/* `stumm` setzt der Host fuer alle, `mikro_aus` der Mensch fuer
sich. Beides sieht man dem Fenster an, und beides heisst
dasselbe: von hier kommt gerade kein Ton. */
fenster.dataset.stumm = (s.stumm || s.mikro_aus) ? 'ja' : 'nein';
let schild = fenster.querySelector('.kamera__aus');
if (s.kamera_aus && !schild) {
schild = el('span', 'kamera__aus', 'Kamera aus');
fenster.append(schild);
} else if (!s.kamera_aus && schild) {
schild.remove();
}
const draht = drähte.get(s.id);
const v = fenster.querySelector('video');
if (s.id === meineId && meinStrom && v.srcObject !== meinStrom) {
@@ -246,6 +262,65 @@
}
}
/* ---- Die Senderleiste --------------------------------------------
Kamera und Mikro gehoeren dem, der sendet -- Host wie Gast. Sie
liegen ueber dem Bild, weil man waehrend einer Sendung dorthin
sieht und nicht auf ein Register.
DER ZUSTAND STEHT AM SERVER, NICHT NUR IM BROWSER. Die Spur
abzuschalten reicht technisch (`track.enabled = false`), aber
dann sehen die anderen ein schwarzes Fenster ohne Grund. Der
Server sagt es weiter, damit „Kamera aus" darin steht. */
function senderleisteZeichnen() {
const leiste = $('senderleiste');
if (!leiste) return;
const sende = !!(lage && binSender && lage.stand === 'live');
leiste.hidden = !sende;
if (!sende) return;
const kAus = !!lage.ich.kamera_aus;
const mAus = !!lage.ich.mikro_aus;
$('s-kamera').setAttribute('aria-pressed', String(kAus));
$('s-mikro').setAttribute('aria-pressed', String(mAus));
$('s-kamera-wort').textContent = kAus ? 'Kamera an' : 'Kamera';
$('s-mikro-wort').textContent = mAus ? 'Mikro an' : 'Mikro';
$('s-kamera').title = kAus ? 'Bild wieder einschalten' : 'Bild abschalten';
$('s-mikro').title = mAus ? 'Mikro wieder einschalten' : 'Mikro abschalten';
}
/** Kamera oder Mikro schalten -- Geraet UND Ansage in einem Zug.
*
* ERST DAS GERAET, DANN DIE ANSAGE. Andersherum stuende bei allen
* „Kamera aus", waehrend noch ein Bild fliesst -- und das ist die
* unangenehmere Reihenfolge: Man glaubt, man sei unsichtbar. */
async function sendenSchalten(was, aus) {
if (!meinStrom) return;
const spuren = was === 'kamera'
? meinStrom.getVideoTracks() : meinStrom.getAudioTracks();
for (const sp of spuren) sp.enabled = !aus;
try {
const a = await hole('/workspace/api/reaktion/senden', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(was === 'kamera' ? { kamera_aus: aus } : { mikro_aus: aus }),
});
if (!a.ok) {
/* Die Ansage ging nicht durch -- also auch das Geraet zurueck.
Ein Knopf, der halb wirkt, ist schlimmer als einer, der gar
nicht wirkt: Man weiss nicht, woran man ist. */
for (const sp of spuren) sp.enabled = aus;
melde(await a.json().catch(() => null), 'Das ging gerade nicht.');
return;
}
lage = { ...(await a.json()) };
zeichnen();
} catch {
for (const sp of spuren) sp.enabled = aus;
sagFehler('Das ging gerade nicht.');
}
}
/* ---- Der Ton ----------------------------------------------------
Er wird EINMAL freigegeben und bleibt dann frei. */
let tonFrei = false;
@@ -827,6 +902,35 @@
/* =================================================================
DER LIVE-CHAT
================================================================= */
/* ---- Die Chat-Stufe ----------------------------------------------
Drei Schalter fuer die zwei, die die Sendung fuehren; fuer alle
anderen die Auskunft, warum das Feld gerade nicht geht.
EIN GESPERRTES FELD MUSS SAGEN, WARUM. Ein Feld, das sich nicht
beschreiben laesst und schweigt, sieht kaputt aus -- und dann
schreibt jemand in den Hauschat, dass die Reaction hakt. */
function chatStufeZeichnen() {
const stufen = $('chat-stufen');
if (!stufen) return;
stufen.hidden = !lage.ich.host;
if (lage.ich.host) {
for (const k of stufen.children) {
k.setAttribute('aria-pressed', String(k.dataset.modus === lage.chat_modus));
}
}
const feld = $('chat-feld');
const senden = document.querySelector('.schiene__senden');
const darf = lage.ich.darf_schreiben !== false;
feld.disabled = !darf;
if (senden) senden.disabled = !darf;
feld.placeholder = darf ? 'Sag was …'
: (lage.chat_modus === 'team'
? 'Gerade schreibt nur das Team'
: 'Der Chat ist gerade zu');
$('schiene').dataset.chat = lage.chat_modus || 'offen';
}
async function chatLaden() {
try {
const a = await hole('/workspace/api/reaktion/chat');
@@ -899,7 +1003,12 @@
er sagt nur, wo der Knopf steht.
================================================================= */
const REGLER = [
{ schluessel: 'mikro', wort: 'Mein Mikro' },
/* `schalter: true` heisst: kein Schieber, nur an/aus und Pegel.
Die eigene Lautstaerke laesst sich nicht am Regler aendern --
dafuer muesste der Ton umgerechnet und die Spur in jeder
Verbindung ausgetauscht werden. Ein Schieber, der sich bewegen
laesst und nichts tut, ist schlimmer als keiner. */
{ schluessel: 'mikro', wort: 'Mein Mikro', schalter: true },
{ schluessel: 'gast1', wort: 'Gast 1' },
{ schluessel: 'gast2', wort: 'Gast 2' },
{ schluessel: 'video', wort: 'YouTube' },
@@ -924,6 +1033,7 @@
Lautstaerke. Einen erfundenen Ausschlag zu zeichnen waere
schlimmer als keiner -- man wuerde ihm glauben. */
let klangRaum = null;
let q2spitze = 0; // die Spitze fuer die Senderleiste
const pegelQuellen = new Map(); // schluessel -> { analyse, feld, spitze }
function klangRaumHolen() {
@@ -956,6 +1066,26 @@
}
function pegelTakt() {
/* Der Pegel in der Senderleiste -- fuer jeden, der sendet. Ein
Gast hat kein Pult, an dem er nachsehen koennte, ob sein Ton
ankommt; er braucht ihn am dringendsten. */
const leistenPegel = $('s-pegel');
if (leistenPegel) {
const q = pegelQuellen.get('mikro');
let hoch = 0;
if (q && !(lage && lage.ich.mikro_aus)) {
q.analyse.getByteTimeDomainData(q.feld);
let summe = 0;
for (let i = 0; i < q.feld.length; i++) {
const w = (q.feld[i] - 128) / 128;
summe += w * w;
}
hoch = Math.min(100, Math.round(Math.sqrt(Math.sqrt(summe / q.feld.length)) * 165));
}
q2spitze = hoch > q2spitze ? hoch : Math.max(hoch, q2spitze - 7);
leistenPegel.style.setProperty('--stand', q2spitze + '%');
}
for (const r of REGLER) {
const kasten = document.querySelector(`.regler[data-was="${r.schluessel}"]`);
if (!kasten) continue;
@@ -1068,28 +1198,33 @@
const reihe = $('regler');
reihe.textContent = '';
for (const r of REGLER) {
const kasten = el('div', 'regler');
const kasten = el('div', 'regler' + (r.schalter ? ' regler--schalter' : ''));
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);
if (!r.schalter) {
const schieber = document.createElement('input');
schieber.type = 'range';
schieber.min = '0'; schieber.max = '100'; schieber.value = '100';
schieber.setAttribute('aria-label', r.wort);
const zahl = el('span', 'regler__zahl', '100');
schieber.addEventListener('input', () => {
staende[r.schluessel] = Number(schieber.value);
zahl.textContent = schieber.value;
tonNachziehen();
});
kasten.append(schieber);
const balken = el('div', 'pegel');
balken.append(el('i'));
kasten.append(balken);
kasten.append(zahl);
} else {
const balken = el('div', 'pegel');
balken.append(el('i'));
kasten.append(balken);
}
const balken = el('div', 'pegel');
balken.append(el('i'));
kasten.append(balken);
kasten.append(zahl);
const stumm = el('button', 'regler__stumm', 'stumm');
const stumm = el('button', 'regler__stumm', r.schalter ? 'aus' : 'stumm');
stumm.type = 'button';
stumm.addEventListener('click', () => stummSchalten(r.schluessel));
kasten.append(stumm);
@@ -1170,11 +1305,10 @@
tastaturAnhaengen();
/* Drei Takte, drei Geschwindigkeiten -- jeder so schnell, wie er
sein muss, und keiner schneller. */
setInterval(pegelTakt, 90); // Pegel: das Auge sieht darunter nichts mehr
/* Zwei Takte, die nur das Pult braucht. Die Pegel laufen
woanders -- sie gehoeren jedem, der sendet. */
setInterval(leisteTakt, 500); // Uhr und Positionsband
setInterval(() => { pegelNachziehen(); lastMessen(); }, 2500);
setInterval(lastMessen, 2500); // was die eigene Leitung traegt
}
function reiterWechseln(name) {
@@ -1187,18 +1321,18 @@
}
function stummSchalten(schluessel) {
/* DAS EIGENE MIKRO IST DERSELBE SCHALTER WIE IN DER SENDERLEISTE
-- ein Zustand, zwei Stellen, die ihn zeigen. Zwei getrennte
Zustaende waeren zwei, die auseinanderlaufen koennen. */
if (schluessel === 'mikro') {
sendenSchalten('mikro', !lage.ich.mikro_aus);
return;
}
const kasten = document.querySelector(`.regler[data-was="${schluessel}"]`);
if (!kasten) return;
const jetztStumm = kasten.dataset.stumm !== 'ja';
kasten.dataset.stumm = jetztStumm ? 'ja' : 'nein';
tonNachziehen();
/* 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 (schluessel === 'mikro' && meinStrom) {
for (const s of meinStrom.getAudioTracks()) s.enabled = !jetztStumm;
}
}
async function layoutSetzen(schluessel) {
@@ -1344,6 +1478,16 @@
const pult = $('pult');
pult.dataset.stand = lage.stand;
/* Der Mikro-Schalter im Pult zeigt denselben Zustand wie der in
der Senderleiste -- er wird hier nur nachgefuehrt, nicht
zweitgefuehrt. */
const mikro = document.querySelector('.regler[data-was="mikro"]');
if (mikro) {
mikro.dataset.stumm = lage.ich.mikro_aus ? 'ja' : 'nein';
const k = mikro.querySelector('.regler__stumm');
if (k) k.textContent = lage.ich.mikro_aus ? 'an' : 'aus';
}
/* ---- Die Leiste ---- */
const worte = { zu: 'Geschlossen', vorbereitung: 'Gleich', live: 'Auf Sendung' };
$('pult-wort').textContent = worte[lage.stand] || '…';
@@ -1734,6 +1878,33 @@
}
async function los() {
/* DIE PEGEL LAUFEN FUER JEDEN, DER SENDET -- und nicht nur fuer
das Pult. Vorher wurden sie in `pultAufbauen()` gestartet, und
das Pult hat nur, wer die Sendung fuehrt: Ein Gast haette
seinen Pegel nie gesehen. */
setInterval(pegelTakt, 90); // 90 ms -- darunter sieht das Auge nichts mehr
setInterval(pegelNachziehen, 2500);
/* Kamera und Mikro -- fuer jeden, der sendet */
$('s-kamera').addEventListener('click',
() => sendenSchalten('kamera', !lage.ich.kamera_aus));
$('s-mikro').addEventListener('click',
() => sendenSchalten('mikro', !lage.ich.mikro_aus));
/* Die Chat-Stufe -- nur fuer die zwei */
for (const k of $('chat-stufen').children) {
k.addEventListener('click', async () => {
const a = await hole('/workspace/api/reaktion/chatmodus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modus: k.dataset.modus }),
});
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
lage = { ...(await a.json()) };
zeichnen();
});
}
/* Der Chat: absenden */
$('chat-form').addEventListener('submit', async (e) => {
e.preventDefault();