From 28b492527e061190712b38c2546fcffadbb723a5 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 28 Sep 2026 08:16:49 +0200 Subject: [PATCH] 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. --- server/mess-reaktion.mjs | 46 ++++++ server/pruef-reaktion.mjs | 159 +++++++++++++++++++- server/reaktion-tabellen.js | 38 +++++ server/workspace-reaktion.js | 136 ++++++++++++++++- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/reaktion.css | 134 +++++++++++++++++ workspace/assets/js/meldung.js | 12 ++ workspace/assets/js/reaktion.js | 233 ++++++++++++++++++++++++++---- workspace/aufgaben.html | 38 ++--- workspace/automation.html | 32 ++-- workspace/befinden.html | 34 ++--- workspace/bereich.html | 44 +++--- workspace/bewerben.html | 38 ++--- workspace/bewerbungen.html | 36 ++--- workspace/calls.html | 34 ++--- workspace/chat.html | 40 ++--- workspace/content.html | 34 ++--- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 ++--- workspace/entwicklung.html | 36 ++--- workspace/hilfe.html | 30 ++-- workspace/index.html | 10 +- workspace/kalender.html | 34 ++--- workspace/leistung.html | 32 ++-- workspace/material.html | 32 ++-- workspace/notizen.html | 28 ++-- workspace/personen.html | 32 ++-- workspace/profil.html | 36 ++--- workspace/reaktion.html | 65 +++++++-- workspace/rechte.html | 34 ++--- workspace/report.html | 36 ++--- workspace/scouting.html | 32 ++-- workspace/start.html | 32 ++-- workspace/startcheck.html | 34 ++--- workspace/steckbrief.html | 36 ++--- workspace/support.html | 28 ++-- workspace/talente.html | 36 ++--- workspace/team.html | 32 ++-- workspace/teamlage.html | 34 ++--- workspace/teilen.html | 30 ++-- workspace/treff-moderation.html | 32 ++-- workspace/treff-regeln.html | 32 ++-- workspace/uebersicht.html | 32 ++-- workspace/unsere-seiten.html | 30 ++-- workspace/unterstuetzen.html | 32 ++-- workspace/werdegang.html | 32 ++-- workspace/willkommen.html | 30 ++-- workspace/wissen.html | 34 ++--- 50 files changed, 1415 insertions(+), 698 deletions(-) diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 4a411b8f..518bd754 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -460,6 +460,52 @@ if (regie.regler !== 4 || regie.pegel !== 4 || regie.knoepfe_sichtbar !== 3) { ausgang = 1; } +/* ---- Kamera aus und Chat-Stufe ------------------------------------ + Beides ist erst gebaut, wenn es beim ZUSCHAUER ankommt. */ +console.log(""); +console.log(" --- Kamera aus, Chat zu ---"); + +await host.seite.click("#s-kamera").catch(() => {}); +await host.seite.waitForTimeout(1200); +const nachKamera = await zuschauer.seite.evaluate(() => { + const f = document.querySelector('#kameras .kamera[data-kamera="aus"]'); + return { + schild: f ? f.querySelector(".kamera__aus")?.textContent?.trim() : null, + wessen: f ? f.querySelector(".kamera__name")?.textContent?.trim() : null, + videoVersteckt: f ? getComputedStyle(f.querySelector("video")).opacity === "0" : false, + }; +}); +console.log(` Zuschauerin sieht: „${nachKamera.schild || "(nichts)"}" ` + + `bei ${nachKamera.wessen || "(niemandem)"}, Bild verdeckt=${nachKamera.videoVersteckt}`); +if (nachKamera.schild !== "Kamera aus" || !nachKamera.videoVersteckt) { + console.log(" ACHTUNG: Das abgeschaltete Bild kommt nicht als Schild an."); + ausgang = 1; +} +await host.seite.click("#s-kamera").catch(() => {}); +await host.seite.waitForTimeout(900); + +await host.seite.click('#chat-stufen .stufe[data-modus="team"]').catch(() => {}); +await host.seite.waitForTimeout(1200); +const nachChat = await zuschauer.seite.evaluate(() => { + const f = document.getElementById("chat-feld"); + return { gesperrt: !!f?.disabled, grund: f?.placeholder || "", + stufe: document.getElementById("schiene")?.dataset.chat }; +}); +console.log(` Zuschauerin: Feld gesperrt=${nachChat.gesperrt} ` + + `Grund „${nachChat.grund}" Stufe=${nachChat.stufe}`); +/* GEGENPROBE AM SERVER. Ein ausgegrautes Feld haelt niemanden auf, + der die Schnittstelle kennt -- also wird auch dort nachgefragt. */ +const trotzdem = await roh("/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "geht das trotzdem?" }, +}); +console.log(` ... und der Server lehnt ab: ${trotzdem.code}/${trotzdem.j?.fehler}`); +if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !== 403) { + console.log(" ACHTUNG: Die Chat-Stufe kommt nicht an."); + ausgang = 1; +} +await host.seite.click('#chat-stufen .stufe[data-modus="offen"]').catch(() => {}); +await host.seite.waitForTimeout(900); + await vermessen(host.seite, "Host am Rechner"); await bild(host.seite, "3-live-host"); diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index 3441881c..412b37d1 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -880,10 +880,163 @@ melde("=== 12. Die Regie ==="); } /* ======================================================================= - 13. KEIN WEG HEISST ZWEIMAL GLEICH + 13. KAMERA UND CHAT — die zwei fehlenden Host-Steuerungen + + Filipes Notiz nennt acht: Kamera, Mikrofon, Video, Gaeste, + Lautstaerke, Chat, Layout, Start/Ende. Nachgezaehlt war sechsmal + etwas da und zweimal nichts. ======================================================================= */ melde(""); -melde("=== 13. Keine doppelten Wege ==="); +melde("=== 13. Kamera und Chat ==="); +{ + /* ABSCHNITT 9 HAT DIE SENDUNG BEENDET -- also wird hier neu + aufgemacht. Sich auf den Stand eines frueheren Abschnitts zu + verlassen ist die Kopplung, die spaeter jemand aus Versehen + zerreisst; dann faellt ein Abschnitt um, an dem nichts falsch + ist. */ + await roh(CREW, "/workspace/api/reaktion", { + method: "PUT", keks: k.admin, + rumpf: { titel: "Kamera und Chat", video: "dQw4w9WgXcQ" }, + }); + await roh(CREW, "/workspace/api/reaktion/stand", { + method: "POST", keks: k.admin, rumpf: { stand: "live" }, + }); + for (const rolle of ["admin", "hand", "linke", "modi", "gast", "gast2"]) { + await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] }); + } + await roh(CREW, "/workspace/api/reaktion/gast", { + method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 }, + }); + + const stand0 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }); + ok(stand0.j?.stand === "live" && stand0.j?.gaeste?.length === 1, + `die Buehne steht (${stand0.j?.stand}, ${stand0.j?.gaeste?.length} Gast)`); + + /* ---- Kamera ---- */ + const fremd = await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.gast, rumpf: { kamera_aus: true }, + }); + ok(fremd.code === 403 && fremd.j?.fehler === "sendest_nicht", + `wer nicht sendet, schaltet nichts (${fremd.code}/${fremd.j?.fehler})`); + + const aus = await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.admin, rumpf: { kamera_aus: true }, + }); + ok(aus.code === 200 && aus.j?.ich?.kamera_aus === true, + `der Host schaltet sein Bild ab (${aus.code})`); + ok(aus.j?.host?.kamera_aus === true, + "und es steht an der Sendung, damit es ALLE sehen"); + + /* TEILWEISE AENDERN DARF DEN ANDEREN WERT NICHT ANFASSEN. Sonst + muesste jede Seite immer beide Werte mitschicken -- und wer das + vergisst, schaltet dem anderen versehentlich das Bild wieder an. */ + const nurMikro = await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.admin, rumpf: { mikro_aus: true }, + }); + ok(nurMikro.j?.ich?.mikro_aus === true && nurMikro.j?.ich?.kamera_aus === true, + "nur das Mikro zu schalten laesst die Kamera in Ruhe"); + + const leer = await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.admin, rumpf: {}, + }); + ok(leer.code === 400 && leer.j?.fehler === "nichts_zu_tun", + `ein Aufruf ohne Inhalt sagt das auch (${leer.code})`); + + /* Ein Gast darf es fuer sich -- sonst muesste er den Host bitten. */ + const gastAus = await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.gast2, rumpf: { kamera_aus: true }, + }); + ok(gastAus.code === 200, + `ein Gast schaltet sein eigenes Bild ab (${gastAus.code})`); + const beimHost = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin }); + ok(beimHost.j?.gaeste?.some((g) => g.id === IDS.gast2 && g.kamera_aus === true), + "und der Host sieht es an der Gastkarte"); + + /* Zurueck, damit der Rest der Pruefung von einem sauberen Stand + aus weiterlaeuft. */ + await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.admin, rumpf: { kamera_aus: false, mikro_aus: false }, + }); + await roh(CREW, "/workspace/api/reaktion/senden", { + method: "POST", keks: k.gast2, rumpf: { kamera_aus: false }, + }); + + /* ---- Chat ---- */ + for (const rolle of ["linke", "modi", "gast"]) { + const a = await roh(CREW, "/workspace/api/reaktion/chatmodus", { + method: "POST", keks: k[rolle], rumpf: { modus: "zu" }, + }); + ok(a.code === 403, `${rolle}: stellt den Chat nicht um (${a.code})`); + } + const unsinn = await roh(CREW, "/workspace/api/reaktion/chatmodus", { + method: "POST", keks: k.admin, rumpf: { modus: "halboffen" }, + }); + ok(unsinn.code === 400 && unsinn.j?.fehler === "chatmodus_unbekannt", + `eine erfundene Stufe wird abgelehnt (${unsinn.code})`); + + const offen = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "offen geht" }, + }); + ok(offen.code === 201, `bei „offen" schreibt die Community (${offen.code})`); + + const team = await roh(CREW, "/workspace/api/reaktion/chatmodus", { + method: "POST", keks: k.hand, rumpf: { modus: "team" }, + }); + ok(team.code === 200 && team.j?.chat_modus === "team", + `die rechte Hand stellt auf „Team" (${team.j?.chat_modus})`); + + const gastTeam = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "und jetzt?" }, + }); + ok(gastTeam.code === 403 && gastTeam.j?.fehler === "chat_nur_team", + `dann schweigt die Community (${gastTeam.code}/${gastTeam.j?.fehler})`); + const modiTeam = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.modi, rumpf: { text: "wir ordnen kurz" }, + }); + ok(modiTeam.code === 201, `ein Modi schon -- er soll ja ordnen (${modiTeam.code})`); + + /* DIE OBERFLAECHE MUSS DIESELBE ANTWORT BEKOMMEN wie der Server. + Sonst steht ein Feld da, das sich beschreiben laesst und beim + Absenden 403 sagt -- eine Falle, keine Bedienung. */ + const sichtGast = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast }); + ok(sichtGast.j?.ich?.darf_schreiben === false, + "und die Oberflaeche erfaehrt es, bevor jemand tippt"); + const sichtModi = await roh(CREW, "/workspace/api/reaktion", { keks: k.modi }); + ok(sichtModi.j?.ich?.darf_schreiben === true, + "der Modi erfaehrt das Gegenteil"); + + const zu = await roh(CREW, "/workspace/api/reaktion/chatmodus", { + method: "POST", keks: k.admin, rumpf: { modus: "zu" }, + }); + ok(zu.j?.chat_modus === "zu", "die Stufe zu geht auch"); + const modiZu = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.modi, rumpf: { text: "auch ich?" }, + }); + ok(modiZu.code === 403 && modiZu.j?.fehler === "chat_zu", + `dann schweigt auch der Modi (${modiZu.code}/${modiZu.j?.fehler})`); + const hostZu = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.admin, rumpf: { text: "ich sage noch etwas" }, + }); + ok(hostZu.code === 201, + `die zwei, die fuehren, koennen weiter reden (${hostZu.code})`); + + /* GEGENPROBE: Es geht auch wieder auf. Eine Sperre, die man nicht + loesen kann, ist keine Stufe, sondern ein Ende. */ + await roh(CREW, "/workspace/api/reaktion/chatmodus", { + method: "POST", keks: k.admin, rumpf: { modus: "offen" }, + }); + const wiederOffen = await roh(CREW, "/workspace/api/reaktion/chat", { + method: "POST", keks: k.gast, rumpf: { text: "wieder da" }, + }); + ok(wiederOffen.code === 201, + `Gegenprobe: zurueck auf „offen", und die Community schreibt wieder (${wiederOffen.code})`); +} + +/* ======================================================================= + 14. KEIN WEG HEISST ZWEIMAL GLEICH + ======================================================================= */ +melde(""); +melde("=== 14. Keine doppelten Wege ==="); { /* Beim Notizblock hat genau das eine Stunde gekostet: `DELETE /workspace/api/notizen/:id` gab es schon woanders, und @@ -907,7 +1060,7 @@ melde("=== 13. Keine doppelten Wege ==="); ok(doppelt.length === 0, doppelt.length ? `DOPPELT: ${[...new Set(doppelt)].join(", ")}` : "kein Weg kommt zweimal vor"); const reaktionswege = wege.filter((x) => x.includes("/api/reaktion")).length; - ok(reaktionswege >= 15, `die Reaction bringt ${reaktionswege} eigene Wege mit`); + ok(reaktionswege >= 17, `die Reaction bringt ${reaktionswege} eigene Wege mit`); } melde(""); diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js index f765806a..cd21a4b5 100644 --- a/server/reaktion-tabellen.js +++ b/server/reaktion-tabellen.js @@ -83,6 +83,19 @@ export const STAENDE = ["zu", "vorbereitung", "live"]; /** Die Anordnungen des Bildes. */ export const LAYOUTS = ["kino", "gleich", "kamera"]; +/** Wer im Live-Chat schreiben darf. + * + * Filipes Notiz nennt „Chat" als eine der acht Host-Steuerungen. + * Gemeint ist nicht die Moderation -- die gab es schon -- sondern + * der Chat selbst: Einen Beitrag wegzunehmen, nachdem er stand, ist + * etwas anderes, als ihn gar nicht erst zuzulassen. + * + * DREI STUFEN UND NICHT ZWEI. „Zu" ist in einer Sendung fast immer + * zu viel -- dann sitzen alle vor einem stummen Fenster. Die + * mittlere Stufe ist die, die man wirklich braucht: Es wird laut, + * das Team ordnet kurz, danach geht es wieder auf. */ +export const CHAT_MODI = ["offen", "team", "zu"]; + /** Hoechstens so viele Videos duerfen in der Warteschlange stehen. * * Die Zahl ist keine technische Grenze, sondern eine Ansage: Wer @@ -216,6 +229,31 @@ export function reaktionTabellen(d) { niemand pflegt, kann nicht veralten; `ADD COLUMN` braucht gar keine. */ const da = new Set(d.prepare("PRAGMA table_info(reaktion)").all().map((z) => z.name)); + if (!da.has("chat_modus")) { + /* Wer im Live-Chat schreiben darf. Gilt fuer die ganze Sendung, + nicht je Person -- deshalb hier und nicht an den Zusehenden. */ + d.exec("ALTER TABLE reaktion ADD COLUMN chat_modus TEXT NOT NULL DEFAULT 'offen'"); + console.log("[reaktion] Spalte chat_modus angelegt."); + } + const dabei = new Set( + d.prepare("PRAGMA table_info(reaktion_dabei)").all().map((z) => z.name)); + if (!dabei.has("kamera_aus")) { + /* Wer sein Bild gerade abgeschaltet hat. + AN DIESER TABELLE UND NICHT AN DEN GAESTEN: Hier steht, wer + gerade da ist -- Host, Gaeste und Zuschauer in einer Liste. + Genau die richtige Lebensdauer: Wer geht, nimmt seinen Zustand + mit, und die Pflege raeumt ihn ohnehin weg. */ + d.exec("ALTER TABLE reaktion_dabei ADD COLUMN kamera_aus INTEGER NOT NULL DEFAULT 0"); + console.log("[reaktion] Spalte kamera_aus angelegt."); + } + if (!dabei.has("mikro_aus")) { + /* Und dasselbe fuers Mikro. Wer sich selbst stummschaltet und + trotzdem redet, saehe sonst bei allen anderen ein ganz + normales Fenster -- niemand merkt, dass nichts ankommt, und er + selbst am wenigsten. */ + d.exec("ALTER TABLE reaktion_dabei ADD COLUMN mikro_aus INTEGER NOT NULL DEFAULT 0"); + console.log("[reaktion] Spalte mikro_aus angelegt."); + } if (!da.has("video_titel")) { /* Der Name des laufenden Videos, einmal bei YouTube geholt. Ohne ihn stuende im Pult und auf der Wartetafel nur die diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index 8ecc1fd8..476f0841 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -47,7 +47,7 @@ import { einstellung } from "./workspace.js"; import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js"; import { SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, - VERLAUF_TAGE, LISTE_MAX, + VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, } from "./reaktion-tabellen.js"; export const reaktionRouter = express.Router(); @@ -212,7 +212,8 @@ function dabeiListe() { const grenze = new Date(Date.now() - 120_000).toISOString(); db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze); return db().prepare(` - SELECT d.person_id AS id, d.sicht, d.seit, p.name, p.rolle + SELECT d.person_id AS id, d.sicht, d.seit, d.kamera_aus, d.mikro_aus, + p.name, p.rolle FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id ORDER BY d.seit`).all(); } @@ -244,10 +245,26 @@ function lage(person) { laeuft: !!s.laeuft, sekunde: s.sekunde, gestartet_am: s.gestartet_am, + /* WER GERADE SEIN BILD AUS HAT -- fuer den Host und fuer jeden + Gast. Ohne diese Auskunft waere ein abgeschaltetes Bild von + einem kaputten nicht zu unterscheiden: beides ein schwarzes + Fenster. Mit ihr steht „Kamera aus" darin, und niemand fragt + nach. */ host: s.host_id - ? db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id) + ? (() => { + const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id); + if (!p) return null; + const z = dabei.find((d) => d.id === p.id); + return { ...p, kamera_aus: !!z?.kamera_aus, mikro_aus: !!z?.mikro_aus }; + })() : null, - gaeste: gaeste.map((g) => ({ id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz })), + gaeste: gaeste.map((g) => ({ + id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz, + kamera_aus: !!(dabei.find((d) => d.id === g.id)?.kamera_aus), + mikro_aus: !!(dabei.find((d) => d.id === g.id)?.mikro_aus), + })), + chat_modus: s.chat_modus || "offen", + chat_modi: CHAT_MODI, dabei: dabei.length, /* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ========== @@ -280,6 +297,9 @@ function lage(person) { gast: binGast, moderiert: MODERIERT.has(person.rolle), sicht: !!(dabei.find((d) => d.id === person.id)?.sicht), + kamera_aus: !!(dabei.find((d) => d.id === person.id)?.kamera_aus), + mikro_aus: !!(dabei.find((d) => d.id === person.id)?.mikro_aus), + darf_schreiben: darfSchreiben(person, s), }, /* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen @@ -301,6 +321,26 @@ function lage(person) { }; } +/** Darf diese Person gerade in den Live-Chat schreiben? + * + * EINE STELLE FUER ZWEI FRAGEN. Der Server muss es wissen, um + * abzulehnen; die Oberflaeche muss es wissen, um das Feld gar nicht + * erst anzubieten. Zwei Rechnungen waeren zwei Gelegenheiten, dass + * ein Feld da ist und mit 403 antwortet -- und das ist eine Falle, + * keine Bedienung. */ +function darfSchreiben(person, stand) { + if (stand.stand === "zu") return false; + const modus = stand.chat_modus || "offen"; + if (modus === "offen") return true; + /* „team": wer moderiert, darf. Das ist dieselbe Menge, die auch + Beitraege wegnehmen darf -- wer aufraeumen soll, muss dabei + reden koennen. */ + if (modus === "team") return MODERIERT.has(person.rolle); + /* „zu": nur die zwei, die die Sendung fuehren. Sonst saesse der + Saal vor einem Fenster, in dem gar nichts mehr passiert. */ + return HOST_ROLLEN.has(person.rolle); +} + function melden(art, daten) { stromAn(empfaenger(), art, daten); } @@ -752,6 +792,86 @@ reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ li } }); +/* ---------- Kamera aus, Kamera an --------------------------------------- + Filipes Notiz nennt „Kamera" als erste der acht Host-Steuerungen. + Sie fehlte: Wer kurz aufstehen, trinken oder etwas holen wollte, + musste die Sendung verlassen oder sich dabei filmen lassen. + + DAS BILD WIRD AM GERAET ABGESCHALTET, NICHT AM SERVER. Die Spur + wird stillgelegt (`track.enabled = false`); die Verbindung bleibt + stehen, der Ton laeuft weiter, und beim Wiedereinschalten ist das + Bild sofort da statt nach einem neuen Verbindungsaufbau. Dieser + Weg hier sagt nur allen ANDEREN Bescheid, damit im Fenster + „Kamera aus" steht und nicht ein schwarzes Rechteck, das nach + einem Fehler aussieht. + + JEDER SENDER DARF DAS FUER SICH -- Host wie Gast. Ein Gast, der + sein eigenes Bild nicht abschalten kann, muesste den Host darum + bitten. */ +reaktionRouter.post("/workspace/api/reaktion/senden", express.json({ limit: "2kb" }), + (req, res) => { + try { + const s = sendung(); + if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + + const istHost = HOST_ROLLEN.has(req.person.rolle); + const binGast = !!db().prepare("SELECT 1 FROM reaktion_gaeste WHERE person_id = ?") + .get(req.person.id); + if (!istHost && !binGast) return res.status(403).json({ fehler: "sendest_nicht" }); + + /* TEILWEISE AENDERN IST ERLAUBT. Wer nur die Kamera schaltet, + schickt nur die Kamera -- sonst muesste jede Seite immer + beide Werte mitschicken und koennte dabei den anderen + versehentlich zuruecksetzen. */ + const felder = []; + const werte = []; + if (req.body?.kamera_aus !== undefined) { + felder.push("kamera_aus = ?"); werte.push(req.body.kamera_aus ? 1 : 0); + } + if (req.body?.mikro_aus !== undefined) { + felder.push("mikro_aus = ?"); werte.push(req.body.mikro_aus ? 1 : 0); + } + if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_tun" }); + + const geschrieben = db().prepare( + `UPDATE reaktion_dabei SET ${felder.join(", ")} WHERE person_id = ?`) + .run(...werte, req.person.id); + /* WER NICHT IN DER LISTE STEHT, SITZT NICHT IM SAAL. Das ist + kein Sonderfall, sondern ein Hinweis: Die Seite hat sich + zwei Minuten nicht gemeldet. Stillschweigend nichts zu tun + waere ein Knopf, der nicht wirkt. */ + if (!geschrieben.changes) return res.status(409).json({ fehler: "nicht_dabei" }); + + const l = lage(req.person); + melden("reaktion", { was: "senden", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Kamera:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ---------- Der Chat als Ganzes ---------------------------------------- + „Chat" aus Filipes Liste der acht Steuerungen. Gemeint ist nicht + die Moderation -- die gab es schon -- sondern der Chat selbst. + Einen Beitrag wegzunehmen, nachdem er stand, ist etwas anderes, + als ihn gar nicht erst zuzulassen. */ +reaktionRouter.post("/workspace/api/reaktion/chatmodus", nurHost, express.json({ limit: "2kb" }), + (req, res) => { + try { + const modus = String(req.body?.modus ?? ""); + if (!CHAT_MODI.includes(modus)) return res.status(400).json({ fehler: "chatmodus_unbekannt" }); + db().prepare("UPDATE reaktion SET chat_modus = ? WHERE id = 1").run(modus); + protokolliere(req.person.id, "reaktion_chatmodus", modus, echteIp(req)); + const l = lage(req.person); + melden("reaktion", { was: "chatmodus", lage: oeffentlich(l) }); + res.json(l); + } catch (fehler) { + console.error("[reaktion] Chatmodus:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }), (req, res) => { try { @@ -785,6 +905,14 @@ reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" try { const s = sendung(); if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" }); + /* DIE STUFE GILT HIER, NICHT NUR IN DER OBERFLAECHE. Ein Feld, + das ausgegraut ist, haelt niemanden auf, der die Schnittstelle + kennt. */ + if (!darfSchreiben(req.person, s)) { + return res.status(403).json({ + fehler: s.chat_modus === "team" ? "chat_nur_team" : "chat_zu", + }); + } const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX); if (!text) return res.status(400).json({ fehler: "leer" }); if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" }); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 32561ef7..0bfb27cc 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +