diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs
index e697bba0..5fb6072e 100644
--- a/server/mess-reaktion.mjs
+++ b/server/mess-reaktion.mjs
@@ -685,6 +685,252 @@ if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice
Abzeichen UND ein zweiter Knopf dazugekommen. Genau so ist am
28.09. die Regie 197 px uebergelaufen: Die Rechnung ging auf, die
Wirklichkeit nicht. */
+/* ---- Das Video: Tempo, Anordnung, Umschalter --------------------
+
+ Filipe: „auch bei den videos sachen wie pause geschwindigkeit ...
+ nur mein bild, nur das video ... auch gerne so dass ich vielleicht
+ noch ein zweites nebenbei vorbereiten kann."
+
+ GEMESSEN WIRD DIE WIRKUNG, NICHT DIE EINSTELLUNG. Dass in der
+ Datenbank 1.5 steht, sagt nichts darüber, ob das Video schneller
+ läuft -- YouTube ignoriert eine unmögliche Geschwindigkeit
+ schweigend. Also wird die Uhr zweimal abgelesen und nachgerechnet. */
+console.log("");
+console.log(" --- Video: Tempo, Anordnung, Umschalter ---");
+
+/* Die Regie zeigt „1:23 / 4:56". Das ist die einzige Stelle, an der
+ der Spielstand als sichtbarer Text steht -- und damit die einzige,
+ an der man von aussen nachrechnen kann. */
+const uhrLesen = (seite) => seite.evaluate(() => {
+ const t = document.getElementById("v-zeit")?.textContent || "";
+ const [links] = t.split("/");
+ const [m, sek] = String(links).trim().split(":").map(Number);
+ return Number.isFinite(m) && Number.isFinite(sek) ? m * 60 + sek : null;
+});
+
+await host.seite.click('#reiter .reiter__knopf[data-tafel="sendung"]').catch(() => {});
+await host.seite.waitForTimeout(600);
+
+const tempoKnoepfe = await host.seite.evaluate(() =>
+ [...document.querySelectorAll("#tempo .tempo__knopf")].map((k2) => ({
+ wort: k2.textContent.trim(),
+ hoch: Math.round(k2.getBoundingClientRect().height),
+ breit: Math.round(k2.getBoundingClientRect().width),
+ })));
+console.log(` ${tempoKnoepfe.length} Tempo-Knoepfe: `
+ + tempoKnoepfe.map((t) => t.wort).join(" "));
+if (tempoKnoepfe.length !== 6 || tempoKnoepfe.some((t) => t.breit < 44)) {
+ console.log(" ACHTUNG: Die Tempo-Knoepfe fehlen oder sind zu schmal.");
+ ausgang = 1;
+}
+
+/* NORMALES TEMPO ZUERST MESSEN -- als Bezugswert. Ohne ihn wüsste man
+ nicht, ob 1,5x wirklich schneller ist oder ob das Video einfach
+ schon lief. */
+await host.seite.evaluate(() => {
+ const k2 = [...document.querySelectorAll("#tempo .tempo__knopf")]
+ .find((x) => Number(x.dataset.tempo) === 1);
+ if (k2) k2.click();
+});
+await host.seite.waitForTimeout(1500);
+const a1 = await uhrLesen(host.seite);
+await host.seite.waitForTimeout(6000);
+const a2 = await uhrLesen(host.seite);
+
+await host.seite.evaluate(() => {
+ const k2 = [...document.querySelectorAll("#tempo .tempo__knopf")]
+ .find((x) => Number(x.dataset.tempo) === 2);
+ if (k2) k2.click();
+});
+await host.seite.waitForTimeout(1800);
+const b1 = await uhrLesen(host.seite);
+await host.seite.waitForTimeout(6000);
+const b2 = await uhrLesen(host.seite);
+
+const normal = (a1 !== null && a2 !== null) ? (a2 - a1) / 6 : null;
+const schnell = (b1 !== null && b2 !== null) ? (b2 - b1) / 6 : null;
+console.log(` 1x: ${normal === null ? "?" : normal.toFixed(2)} Sekunden je Sekunde · `
+ + `2x: ${schnell === null ? "?" : schnell.toFixed(2)}`);
+if (normal === null || schnell === null || schnell < normal * 1.5) {
+ console.log(" ACHTUNG: Die Geschwindigkeit wirkt nicht -- das Video laeuft gleich schnell.");
+ ausgang = 1;
+}
+
+/* UND DER ZUSCHAUER? Er muss es SEHEN -- sonst haelt er seine Leitung
+ fuer kaputt. */
+await zuschauer.seite.waitForTimeout(1200);
+const schild = await zuschauer.seite.evaluate(() => {
+ const s2 = document.getElementById("leinwand-tempo");
+ return {
+ versteckt: !!s2?.hidden, wort: s2?.textContent || "",
+ amKino: document.getElementById("teil-live")?.dataset.tempo,
+ };
+});
+console.log(` Die Zuschauerin sieht: „${schild.wort}" `
+ + `(versteckt=${schild.versteckt}, am Kino ${schild.amKino})`);
+if (schild.versteckt || !schild.wort.includes("2") || schild.amKino !== "2") {
+ console.log(" ACHTUNG: Der Zuschauerin wird die Geschwindigkeit nicht gezeigt.");
+ ausgang = 1;
+}
+await host.seite.evaluate(() => {
+ const k2 = [...document.querySelectorAll("#tempo .tempo__knopf")]
+ .find((x) => Number(x.dataset.tempo) === 1);
+ if (k2) k2.click();
+});
+await zuschauer.seite.waitForTimeout(1200);
+const wieder = await zuschauer.seite.evaluate(() =>
+ !!document.getElementById("leinwand-tempo")?.hidden);
+console.log(` Zurueck auf 1x: Schild wieder weg = ${wieder}`);
+if (!wieder) {
+ console.log(" ACHTUNG: Das Schild bleibt stehen, obwohl es normal laeuft.");
+ ausgang = 1;
+}
+
+/* ---- Die fuenf Anordnungen, beim Zuschauer gemessen ---- */
+await host.seite.click('#reiter .reiter__knopf[data-tafel="bild"]').catch(() => {});
+await host.seite.waitForTimeout(500);
+const anordnungen = {};
+for (const [name, was] of [
+ ["kino", "Video groß"], ["gleich", "Gleich groß"], ["kamera", "Kameras groß"],
+ ["nur_video", "Nur Video"], ["nur_kamera", "Nur Kamera"],
+]) {
+ await host.seite.click(`#layout-reihe .layout-knopf[data-layout="${name}"]`).catch(() => {});
+ await host.seite.waitForTimeout(1100);
+ anordnungen[name] = await zuschauer.seite.evaluate(() => {
+ const sicht = (w) => {
+ const e = document.querySelector(w);
+ if (!e) return "(fehlt)";
+ const c = getComputedStyle(e);
+ const r = e.getBoundingClientRect();
+ return c.display === "none" ? "weg" : `${Math.round(r.width)}x${Math.round(r.height)}`;
+ };
+ /* LAEUFT ETWAS UEBER? Die Leinwand hat `overflow: hidden` --
+ ein Kamerafenster, das daneben liegt, ist damit unsichtbar
+ und nicht etwa abgeschnitten sichtbar. Genau so verschwand am
+ 28.09. bei „nur Kamera“ der zweite Gast komplett: Behaelter
+ 2175 px breit in einer 1072 px breiten Leinwand. Auf dem Bild
+ sah es aus wie eine Anordnung fuer einen -- und niemand haette
+ gefragt, wo der zweite geblieben ist. */
+ const lein = document.querySelector(".leinwand")?.getBoundingClientRect();
+ const kk = document.querySelector(".kameras")?.getBoundingClientRect();
+ const fenster = [...document.querySelectorAll(".kamera")];
+ return {
+ layout: document.getElementById("teil-live")?.dataset.layout,
+ video: sicht(".leinwand__video"), kameras: sicht(".kameras"),
+ kamera: sicht(".kamera"),
+ ueber: (lein && kk) ? Math.round(Math.max(0, kk.width - lein.width)) : 0,
+ fenster: fenster.length,
+ /* Und liegt auch jedes einzelne innerhalb? */
+ drin: !lein ? 0 : fenster.filter((f) => {
+ const r = f.getBoundingClientRect();
+ return r.left >= lein.left - 1 && r.right <= lein.right + 1
+ && r.top >= lein.top - 1 && r.bottom <= lein.bottom + 1;
+ }).length,
+ };
+ });
+ const m = anordnungen[name];
+ console.log(` ${was.padEnd(12)} (${name}): Video ${m.video}, `
+ + `Kameras ${m.kameras}, eine ${m.kamera}, ${m.drin}/${m.fenster} im Bild`
+ + (m.ueber ? `, UEBER ${m.ueber} px` : ""));
+ if (m.ueber > 0 || (m.fenster > 0 && m.drin !== m.fenster && name !== "nur_video")) {
+ console.log(` ACHTUNG: Bei „${was}“ liegt ein Kamerafenster ausserhalb der Leinwand.`);
+ ausgang = 1;
+ }
+ await bild(zuschauer.seite, `15-anordnung-${name}`);
+}
+/* DAS WEGLASSEN MUSS WIRKLICH WEGLASSEN. `display: none` und nicht
+ nur klein -- ein unsichtbarer YouTube-Rahmen spielt weiter und
+ haelt den Ton. */
+if (anordnungen.nur_video.kameras !== "weg") {
+ console.log(" ACHTUNG: Bei „nur Video“ stehen die Kameras noch da.");
+ ausgang = 1;
+}
+if (anordnungen.nur_kamera.video !== "weg") {
+ console.log(" ACHTUNG: Bei „nur Kamera“ steht das Video noch da.");
+ ausgang = 1;
+}
+/* UND DIE KAMERAS MUESSEN DABEI GROSS WERDEN. Ein Weglassen, nach dem
+ zwei Briefmarken in einer schwarzen Flaeche stehen, ist keine
+ Anordnung, sondern ein Fehler. */
+const klein = Number(String(anordnungen.kino.kamera).split("x")[0]) || 0;
+const gross = Number(String(anordnungen.nur_kamera.kamera).split("x")[0]) || 0;
+console.log(` Kamerabreite: kino ${klein} px -> nur_kamera ${gross} px`);
+if (gross <= klein * 1.5) {
+ console.log(" ACHTUNG: Bei „nur Kamera“ wird die Kamera nicht groesser.");
+ ausgang = 1;
+}
+await host.seite.click('#layout-reihe .layout-knopf[data-layout="kino"]').catch(() => {});
+await host.seite.waitForTimeout(800);
+
+/* ---- Der Umschalter zwischen zwei Videos ---- */
+await host.seite.click('#reiter .reiter__knopf[data-tafel="sendung"]').catch(() => {});
+await host.seite.waitForTimeout(500);
+const vorherVerborgen = await host.seite.evaluate(() =>
+ !!document.getElementById("v-tauschen")?.hidden);
+console.log(` Ohne zweites Video ist der Umschalter verborgen: ${vorherVerborgen}`);
+if (!vorherVerborgen) {
+ console.log(" ACHTUNG: Ein Umschalter ohne Gegenstueck ist eine Falle.");
+ ausgang = 1;
+}
+
+await host.seite.fill("#p-zweit", "https://youtu.be/9bZkp7q19f0").catch(() => {});
+await host.seite.click("#p-zweit-sichern").catch(() => {});
+await host.seite.waitForTimeout(2600);
+const umschalter = await host.seite.evaluate(() => {
+ const k2 = document.getElementById("v-tauschen");
+ const r = k2.getBoundingClientRect();
+ return {
+ versteckt: !!k2.hidden, wort: k2.textContent.replace(/\s+/g, " ").trim(),
+ breit: Math.round(r.width), hoch: Math.round(r.height),
+ laeuftJetzt: document.getElementById("spur-titel")?.textContent || "",
+ };
+});
+console.log(` Umschalter: „${umschalter.wort}" (${umschalter.breit}x${umschalter.hoch} px)`);
+console.log(` Es laeuft: „${umschalter.laeuftJetzt}"`);
+if (umschalter.versteckt || umschalter.hoch < 40) {
+ console.log(" ACHTUNG: Der Umschalter kommt nicht oder ist zu klein.");
+ ausgang = 1;
+}
+
+/* EINMAL HIN. Die Stelle muss sich merken -- ohne das waere „hin und
+ her" ein Neustart von vorn, und dann schaltet niemand zweimal. */
+const stelleVorher = await uhrLesen(host.seite);
+await host.seite.click("#v-tauschen");
+await host.seite.waitForTimeout(3000);
+const nachHin = await host.seite.evaluate(() => ({
+ titel: document.getElementById("spur-titel")?.textContent || "",
+ zurueckZu: document.getElementById("v-tausch-titel")?.textContent || "",
+ zeit: document.getElementById("v-zeit")?.textContent || "",
+}));
+console.log(` Nach dem Wechseln laeuft: „${nachHin.titel}", zurueck zu „${nachHin.zurueckZu}"`);
+
+/* UND ZURUECK. */
+await host.seite.click("#v-tauschen");
+await host.seite.waitForTimeout(3000);
+const nachZurueck = await host.seite.evaluate(() => ({
+ titel: document.getElementById("spur-titel")?.textContent || "",
+ zeit: document.getElementById("v-zeit")?.textContent || "",
+}));
+const stelleNachher = await uhrLesen(host.seite);
+console.log(` Zurueck bei: „${nachZurueck.titel}", Uhr ${nachZurueck.zeit} `
+ + `(vorher war ${stelleVorher} s, jetzt ${stelleNachher} s)`);
+if (nachHin.titel === nachZurueck.titel) {
+ console.log(" ACHTUNG: Der Umschalter wechselt nicht.");
+ ausgang = 1;
+}
+/* Die gemerkte Stelle: etwa dort weiter, wo es aufgehoert hat --
+ sechs Sekunden Spiel fuer das Laden des Videos. */
+if (stelleVorher === null || stelleNachher === null
+ || Math.abs(stelleNachher - stelleVorher) > 8) {
+ console.log(` ACHTUNG: Die gemerkte Stelle ging verloren `
+ + `(${stelleVorher} -> ${stelleNachher}).`);
+ ausgang = 1;
+}
+await bild(host.seite, "16-umschalter");
+await host.seite.fill("#p-zweit", "").catch(() => {});
+await host.seite.click("#p-zweit-sichern").catch(() => {});
+await host.seite.waitForTimeout(900);
+
console.log("");
console.log(" --- Rollen und Moderation ---");
const modi = await alsWer("modi", 1440, 900);
diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs
index 2e8d1971..bbe12671 100644
--- a/server/pruef-buehne.mjs
+++ b/server/pruef-buehne.mjs
@@ -167,7 +167,13 @@ melde("=== 2. Der Schluessel ===");
hat, soll genau das sehen, was ohnehin im Stream steht -- und
nichts ueber das Haus. */
const felder = Object.keys(gut.j || {}).sort();
- const erlaubt = ["gesendet", "laeuft", "sekunde", "stand", "titel", "video", "video_titel"];
+ /* `tempo` kam am 28.09. dazu -- die Quelle muss die Geschwindigkeit
+ mitmachen, sonst liefe der Stream anders als der Saal. Die Liste
+ wird von Hand gepflegt UND ist absichtlich abschliessend: Sie ist
+ die einzige Stelle, an der auffaellt, wenn jemand dem Stand ein
+ Feld hinzufuegt, das ohne Anmeldung abrufbar waere. */
+ const erlaubt = ["gesendet", "laeuft", "sekunde", "stand", "tempo",
+ "titel", "video", "video_titel"];
ok(JSON.stringify(felder) === JSON.stringify(erlaubt),
`die Auskunft hat genau die erlaubten Felder (${felder.join(", ")})`);
for (const verboten of ["zuschauer", "dabei", "gaeste", "chat", "host", "ich", "liste"]) {
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
index b9846715..7d958a58 100644
--- a/server/pruef-reaktion.mjs
+++ b/server/pruef-reaktion.mjs
@@ -1357,6 +1357,201 @@ melde("=== 16. Rollen und Moderation auf der Seite ===");
doppelt.length ? `DOPPELT: ${doppelt.join(", ")}` : `${schluessel.length} Saetze, keiner doppelt`);
}
+
+/* =======================================================================
+ 17. DAS VIDEO: TEMPO, ANORDNUNG, DAS ZWEITE
+
+ Filipe: „auch bei den videos sachen wie pause geschwindigkeit.
+ meine kamera groesser machen video kleiner. nur mein bild, nur das
+ video ... auch gerne so dass ich vielleicht noch ein zweites
+ nebenbei vorbereiten kann so dass ich hin und her switchen kann."
+ ======================================================================= */
+melde("");
+melde("=== 17. Tempo, Anordnung und das zweite Video ===");
+{
+ const { TEMPI, LAYOUTS } = await import("./reaktion-tabellen.js");
+
+ /* ---- Die Anordnungen ---- */
+ ok(LAYOUTS.length === 5 && LAYOUTS.includes("nur_video") && LAYOUTS.includes("nur_kamera"),
+ `es gibt fuenf Anordnungen: ${LAYOUTS.join(", ")}`);
+ for (const l of LAYOUTS) {
+ const a = await roh(CREW, "/workspace/api/reaktion/layout", {
+ method: "POST", keks: k.admin, rumpf: { layout: l },
+ });
+ ok(a.code === 200 && a.j?.layout === l, `„${l}" laesst sich stellen (${a.code})`);
+ }
+ const quatschL = await roh(CREW, "/workspace/api/reaktion/layout", {
+ method: "POST", keks: k.admin, rumpf: { layout: "diagonal" },
+ });
+ ok(quatschL.code === 400 && quatschL.j?.fehler === "layout_unbekannt",
+ `eine erfundene Anordnung wird abgelehnt (${quatschL.code})`);
+ const nichtHost = await roh(CREW, "/workspace/api/reaktion/layout", {
+ method: "POST", keks: k.modi, rumpf: { layout: "kino" },
+ });
+ ok(nichtHost.code === 403, `ein Modi stellt die Anordnung nicht (${nichtHost.code})`);
+ await roh(CREW, "/workspace/api/reaktion/layout", {
+ method: "POST", keks: k.admin, rumpf: { layout: "kino" },
+ });
+
+ /* JEDE ANORDNUNG BRAUCHT AUCH EINE REGEL -- sonst ist sie ein
+ Knopf, der nichts tut. „kino" ist die Vorgabe und braucht keine. */
+ {
+ const css = readFileSync(join(HIER, "..", "workspace", "assets", "css", "reaktion.css"), "utf8");
+ const ohne = LAYOUTS.filter((l) => l !== "kino")
+ .filter((l) => !css.includes(`[data-layout="${l}"]`));
+ ok(ohne.length === 0,
+ ohne.length ? `OHNE REGEL: ${ohne.join(", ")}` : "jede Anordnung hat eine eigene Regel");
+ ok(!css.includes('[data-layout="diagonal"]'),
+ "Gegenprobe: eine erfundene Anordnung hat keine");
+ /* KEIN `opacity: 0` FUER DAS WEGLASSEN. Ein unsichtbarer
+ YouTube-Rahmen spielt weiter -- man hoerte ein Video, das
+ niemand sieht. */
+ const nurKamera = css.match(/\.kino\[data-layout="nur_kamera"\] \.leinwand__video \{[^}]*\}/);
+ ok(!!nurKamera && /display:\s*none/.test(nurKamera[0]),
+ "„nur Kamera“ nimmt das Video ganz weg und macht es nicht nur durchsichtig");
+ }
+
+ /* ---- Die Geschwindigkeit ---- */
+ ok(Array.isArray(TEMPI) && TEMPI.includes(1) && TEMPI.includes(0.5) && TEMPI.includes(2),
+ `es gibt ${TEMPI.length} Geschwindigkeiten: ${TEMPI.join(", ")}`);
+ /* YouTube kann 0,25 bis 2. Alles ausserhalb waere ein Knopf, den
+ der Spieler schweigend ignoriert -- und dann steht die Regie auf
+ 3x und es laeuft normal. */
+ ok(TEMPI.every((t) => t >= 0.25 && t <= 2), "und alle liegen in dem, was YouTube kann");
+
+ for (const t of [1.5, 0.5, 1]) {
+ const a = await roh(CREW, "/workspace/api/reaktion/video", {
+ method: "POST", keks: k.admin, rumpf: { tempo: t, laeuft: true, sekunde: 12 },
+ });
+ ok(a.code === 200 && a.j?.tempo === t, `${t}-fach laesst sich stellen (${a.code})`);
+ }
+ for (const t of [3, 0.1, "schnell", 1.1]) {
+ const a = await roh(CREW, "/workspace/api/reaktion/video", {
+ method: "POST", keks: k.admin, rumpf: { tempo: t, laeuft: true, sekunde: 12 },
+ });
+ ok(a.code === 400 && a.j?.fehler === "tempo_unklar",
+ `„${t}" wird abgelehnt (${a.code})`);
+ }
+
+ /* DER TAKT DARF DAS TEMPO NICHT ZURUECKDREHEN. Die Videoroute
+ laeuft alle fuenf Sekunden; schriebe sie das Tempo jedes Mal mit,
+ wuerde ein Takt mit altem Wert die Einstellung ueberschreiben. */
+ await roh(CREW, "/workspace/api/reaktion/video", {
+ method: "POST", keks: k.admin, rumpf: { tempo: 1.5, laeuft: true, sekunde: 20 },
+ });
+ await roh(CREW, "/workspace/api/reaktion/video", {
+ method: "POST", keks: k.admin, rumpf: { laeuft: true, sekunde: 25 },
+ });
+ const nachTakt = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
+ ok(nachTakt.j?.tempo === 1.5,
+ `ein Takt ohne Tempo laesst es stehen (${nachTakt.j?.tempo})`);
+
+ /* UND ES GEHT AN ALLE. Wer sie nicht bekaeme, saehe dasselbe Video
+ in einem anderen Tempo. */
+ ok(nachTakt.j?.tempo === 1.5 && Array.isArray(nachTakt.j?.tempi),
+ "auch ein Zuschauer erfaehrt Tempo und Auswahl");
+ await roh(CREW, "/workspace/api/reaktion/video", {
+ method: "POST", keks: k.admin, rumpf: { tempo: 1, laeuft: true, sekunde: 25 },
+ });
+
+ /* ---- Das zweite Video ---- */
+ const A = "dQw4w9WgXcQ";
+ const B = "9bZkp7q19f0";
+ await roh(CREW, "/workspace/api/reaktion/video", {
+ method: "POST", keks: k.admin, rumpf: { video: A, laeuft: true, sekunde: 0 },
+ });
+
+ const nurHost2 = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
+ method: "POST", keks: k.modi, rumpf: { video: B },
+ });
+ ok(nurHost2.code === 403, `ein Modi legt kein zweites Video bereit (${nurHost2.code})`);
+
+ const selbes = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
+ method: "POST", keks: k.admin, rumpf: { video: A },
+ });
+ ok(selbes.code === 409 && selbes.j?.fehler === "schon_im_bild",
+ `dasselbe Video auf beide Seiten geht nicht (${selbes.code})`);
+
+ const ohneZweites = await roh(CREW, "/workspace/api/reaktion/tauschen", {
+ method: "POST", keks: k.admin, rumpf: {},
+ });
+ ok(ohneZweites.code === 409 && ohneZweites.j?.fehler === "kein_zweites",
+ `ohne zweites Video gibt es nichts zu tauschen (${ohneZweites.code})`);
+
+ const gelegt = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
+ method: "POST", keks: k.admin, rumpf: { video: `https://youtu.be/${B}` },
+ });
+ ok(gelegt.code === 200 && gelegt.j?.zweit?.video === B,
+ `eine ganze Adresse wird verstanden (${gelegt.j?.zweit?.video})`);
+
+ /* ES GEHT NICHT AN ALLE. Was daneben vorbereitet ist, ist die
+ Rueckhand -- die verraet man nicht vorher. */
+ const beimGast = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
+ ok(beimGast.j?.zweit === null, "ein Zuschauer sieht das zweite Video nicht");
+ {
+ const quelle = readFileSync(join(HIER, "workspace-reaktion.js"), "utf8");
+ const zeile = quelle.match(/const \{([^}]*)\} = l;/);
+ ok((zeile ? zeile[1] : "").includes("zweit"),
+ "und es steht auch nicht im Rundruf");
+ }
+
+ /* ---- Hin und her, und jedes merkt sich seine Stelle ---- */
+ const hin = await roh(CREW, "/workspace/api/reaktion/tauschen", {
+ method: "POST", keks: k.admin, rumpf: { sekunde: 87.5 },
+ });
+ ok(hin.code === 200 && hin.j?.video === B, `getauscht: jetzt laeuft B (${hin.j?.video})`);
+ ok(hin.j?.zweit?.video === A && Math.abs((hin.j?.zweit?.sekunde || 0) - 87.5) < 0.01,
+ `und A liegt bei 87,5 s bereit (${hin.j?.zweit?.sekunde})`);
+ ok(Math.abs((hin.j?.sekunde || 0) - 0) < 0.01,
+ `B faengt bei 0 an -- es lief noch nie (${hin.j?.sekunde})`);
+
+ const zurueck = await roh(CREW, "/workspace/api/reaktion/tauschen", {
+ method: "POST", keks: k.admin, rumpf: { sekunde: 31 },
+ });
+ ok(zurueck.code === 200 && zurueck.j?.video === A, `zurueck: wieder A (${zurueck.j?.video})`);
+ ok(Math.abs((zurueck.j?.sekunde || 0) - 87.5) < 0.01,
+ `und zwar GENAU DORT, wo es aufgehoert hat: ${zurueck.j?.sekunde} s`);
+ ok(Math.abs((zurueck.j?.zweit?.sekunde || 0) - 31) < 0.01,
+ `B merkt sich jetzt 31 s (${zurueck.j?.zweit?.sekunde})`);
+
+ /* GEGENPROBE: Ohne die gemerkte Stelle waere „hin und her" ein
+ Neustart von vorn -- und dann schaltet niemand zweimal. */
+ ok((zurueck.j?.sekunde || 0) > 1,
+ "Gegenprobe: die Stelle ist nicht einfach null");
+
+ /* ---- Wegraeumen ---- */
+ const weg = await roh(CREW, "/workspace/api/reaktion/zweitvideo", {
+ method: "POST", keks: k.admin, rumpf: { video: "" },
+ });
+ ok(weg.code === 200 && weg.j?.zweit === null, `ein leeres Feld raeumt es weg (${weg.code})`);
+ const danach = await roh(CREW, "/workspace/api/reaktion/tauschen", {
+ method: "POST", keks: k.admin, rumpf: {},
+ });
+ ok(danach.code === 409, `und danach gibt es nichts mehr zu tauschen (${danach.code})`);
+
+ /* ---- Und die Seite? ---- */
+ {
+ const js = readFileSync(join(HIER, "..", "workspace", "assets", "js", "reaktion.js"), "utf8");
+ const buehne = readFileSync(join(HIER, "..", "workspace", "assets", "js", "buehne.js"), "utf8");
+ const html = readFileSync(join(HIER, "..", "workspace", "reaktion.html"), "utf8");
+ ok(html.includes('id="tempo"') && html.includes('id="v-tauschen"')
+ && html.includes('id="p-zweit"'),
+ "die Regie hat Tempo, Umschalter und das Feld fuers zweite Video");
+ /* BEIDE SPIELER MUESSEN DAS TEMPO NACHZIEHEN -- der im Saal und
+ der in OBS. Zieht nur einer nach, laeuft der Stream anders als
+ der Saal, und das faellt erst nach Minuten auf. */
+ for (const [name, quelle] of [["die Seite", js], ["die Buehne", buehne]]) {
+ ok(/setPlaybackRate/.test(quelle), `${name} stellt die Geschwindigkeit`);
+ /* Und nach einem Videowechsel nochmal: YouTube setzt sie beim
+ Laden auf 1 zurueck. */
+ ok(/loadVideoById[\s\S]{0,400}tempoNachziehen/.test(quelle),
+ `${name} zieht sie auch nach einem Videowechsel nach`);
+ ok(/getAvailablePlaybackRates/.test(quelle),
+ `${name} setzt keinen Wert, den der Spieler nicht kann`);
+ }
+ }
+}
+
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js
index 39ce88d9..ac8c7223 100644
--- a/server/reaktion-tabellen.js
+++ b/server/reaktion-tabellen.js
@@ -80,8 +80,31 @@ export const SCHREIB_BREMSE = 20;
/** Die Staende, die es gibt -- und nur diese. */
export const STAENDE = ["zu", "vorbereitung", "live"];
-/** Die Anordnungen des Bildes. */
-export const LAYOUTS = ["kino", "gleich", "kamera"];
+/** Die Anordnungen des Bildes.
+ *
+ * Filipe: „meine kamera groesser machen video kleiner. nur mein
+ * bild, nur das video, moeglichkeit zwischen allem zu wechseln so
+ * wie ich will."
+ *
+ * Die ersten drei sind Groessenverhaeltnisse, die letzten zwei ein
+ * WEGLASSEN -- und das ist etwas anderes. „nur_video" und
+ * „nur_kamera" braucht man vor allem fuer OBS: Dort liegt die
+ * Kamera ohnehin als eigene Quelle, und die Buehnenseite soll dann
+ * nichts doppelt zeigen. */
+export const LAYOUTS = ["kino", "gleich", "kamera", "nur_video", "nur_kamera"];
+
+/** Die Geschwindigkeiten, die es gibt.
+ *
+ * YouTube kann 0,25 bis 2. Die 0,25 fehlt hier mit Absicht: Bei
+ * einem Viertel klingt Sprache wie ein defektes Band, und in einer
+ * Live-Sendung, in der nebenher geredet wird, ist das keine
+ * Geschwindigkeit, sondern ein Aussetzer.
+ *
+ * WICHTIG IST, DASS SIE FUER ALLE GILT. Wer sie nur bei sich
+ * umstellt, redet ueber eine Stelle, die die anderen noch nicht
+ * gesehen haben -- deshalb steht sie an der Sendung und nicht im
+ * Browser. */
+export const TEMPI = [0.5, 0.75, 1, 1.25, 1.5, 2];
/** Wie lange eine Stummschaltung hoechstens gilt (Minuten).
*
@@ -310,6 +333,30 @@ export function reaktionTabellen(d) {
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("tempo")) {
+ /* Die Geschwindigkeit gilt fuer die ganze Sendung.
+ REAL und nicht INTEGER: 0,75 und 1,25 sind die beiden, die man
+ im Alltag am haeufigsten nimmt. */
+ d.exec("ALTER TABLE reaktion ADD COLUMN tempo REAL NOT NULL DEFAULT 1");
+ console.log("[reaktion] Spalte tempo angelegt.");
+ }
+ if (!da.has("zweit_video")) {
+ /* ==== DAS ZWEITE VIDEO ========================================
+
+ Drei Spalten und keine eigene Tabelle. Es ist GENAU EINES --
+ ein Umschalter hat zwei Seiten, sonst ist er eine Liste, und
+ die gibt es schon (`reaktion_liste`).
+
+ DIE SEKUNDE IST DER GANZE PUNKT. Ohne sie waere „hin und her"
+ ein Neustart von vorn, und dann schaltet niemand zweimal.
+ Beim Tauschen wird die Stelle des gerade laufenden Videos hier
+ hineingeschrieben und die gemerkte Stelle des anderen
+ herausgeholt. */
+ d.exec("ALTER TABLE reaktion ADD COLUMN zweit_video TEXT NOT NULL DEFAULT ''");
+ d.exec("ALTER TABLE reaktion ADD COLUMN zweit_titel TEXT NOT NULL DEFAULT ''");
+ d.exec("ALTER TABLE reaktion ADD COLUMN zweit_sekunde REAL NOT NULL DEFAULT 0");
+ console.log("[reaktion] Spalten fuer das zweite Video 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-buehne.js b/server/workspace-buehne.js
index c51c185a..395bc92c 100644
--- a/server/workspace-buehne.js
+++ b/server/workspace-buehne.js
@@ -141,6 +141,12 @@ buehneRouter.get("/workspace/api/buehne/lage", mitSchluessel, (req, res) => {
video_titel: s?.video_titel || "",
laeuft: !!s?.laeuft,
sekunde: s?.sekunde || 0,
+ /* DIE GESCHWINDIGKEIT MUSS MIT. Ohne sie liefe das Video in
+ OBS immer auf 1,0 -- und sobald die Regie auf 1,5 stellt,
+ liefe der Stream langsamer als der Saal. Nach fuenf Minuten
+ waeren das zweieinhalb Minuten Unterschied, und was Filipe
+ kommentiert, haetten die Zuschauer noch gar nicht gesehen. */
+ tempo: Number(s?.tempo) || 1,
gesendet: Date.now(),
titel: s?.titel || "",
});
diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js
index 9f8da8b6..994db5d2 100644
--- a/server/workspace-reaktion.js
+++ b/server/workspace-reaktion.js
@@ -47,7 +47,7 @@ import { buehneMelden } from "./workspace-buehne.js";
import { einstellung } from "./workspace.js";
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
import {
- SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS,
+ SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS, TEMPI,
VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN,
} from "./reaktion-tabellen.js";
/* DIE SPERREN DES TREFFS GELTEN AUCH HIER. Ein zweites System
@@ -250,6 +250,12 @@ function lage(person) {
layout: s.layout,
laeuft: !!s.laeuft,
sekunde: s.sekunde,
+ /* DIE GESCHWINDIGKEIT GEHT AN ALLE. Sie ist kein Regieknopf,
+ sondern Teil dessen, was gerade laeuft -- wer sie nicht
+ bekaeme, saehe dasselbe Video in einem anderen Tempo und waere
+ nach fuenf Minuten zwei Minuten woanders. */
+ tempo: Number(s.tempo) || 1,
+ tempi: TEMPI,
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
@@ -332,6 +338,18 @@ function lage(person) {
return m ? { art: m.art, bis: m.bis, grund: m.grund } : null;
})(),
},
+ /* ==== DAS ZWEITE VIDEO SIEHT NUR, WER SENDET ==================
+
+ Was als Naechstes kommt, darf jeder wissen (`naechstes`) --
+ das macht Lust. Was DANEBEN vorbereitet ist, nicht: Es ist die
+ Rueckhand fuer den Fall, dass etwas nicht laeuft, und die
+ verraet man nicht vorher. Dieselbe Linie wie bei der
+ Warteschlange. */
+ zweit: istHost
+ ? (s.zweit_video
+ ? { video: s.zweit_video, titel: s.zweit_titel || "", sekunde: s.zweit_sekunde || 0 }
+ : null)
+ : null,
/* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der
Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen
alles sehen, vorbereiten und einschakten koennen." */
@@ -727,7 +745,21 @@ reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ lim
if (req.body?.video !== undefined && !video) {
return res.status(400).json({ fehler: "video_unklar" });
}
+ /* DIE GESCHWINDIGKEIT WIRD NUR GESETZT, WENN SIE MITKOMMT.
+ Diese Route laeuft alle fuenf Sekunden -- schriebe sie das
+ Tempo jedes Mal mit, wuerde ein Takt des Hosts, der noch die
+ alte Zahl im Kopf hat, die Einstellung eines anderen
+ zurueckdrehen. Ein Wert, der nicht gemeint ist, wird nicht
+ geschrieben. */
+ let tempo = null;
+ if (req.body?.tempo !== undefined) {
+ tempo = Number(req.body.tempo);
+ if (!TEMPI.includes(tempo)) return res.status(400).json({ fehler: "tempo_unklar" });
+ }
const s0 = sendung();
+ if (tempo !== null) {
+ db().prepare("UPDATE reaktion SET tempo = ? WHERE id = 1").run(tempo);
+ }
if (video) {
db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1")
.run(video, laeuft, sekunde, jetzt());
@@ -750,18 +782,130 @@ reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ lim
melden("reaktion", {
was: "video",
video: s.video, laeuft: !!s.laeuft, sekunde: s.sekunde,
+ tempo: Number(s.tempo) || 1,
/* Die Uhrzeit des Servers geht mit: Der Empfaenger rechnet
die Laufzeit seit dem Absenden dazu. Ohne das waere jeder
genau so weit hinten, wie sein Netz langsam ist. */
gesendet: Date.now(),
});
- res.json({ ok: true });
+ res.json({ ok: true, tempo: Number(s.tempo) || 1 });
} catch (fehler) {
console.error("[reaktion] Video:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
+/* ---------- Das zweite Video -------------------------------------------
+ Filipe: „auch gerne so dass ich vielleicht noch ein zweites
+ nebenbei vorbereiten kann so dass ich hin und her switchen kann wie
+ ich es brauche und wil."
+
+ WARUM DAS NICHT DIE WARTESCHLANGE IST. Die Schlange ist eine
+ Reihenfolge -- eins nach dem anderen, und das Gespielte ist
+ danach heraus. Hier geht es um zwei Videos NEBENEINANDER, und
+ jedes merkt sich seine Stelle. Mit der Schlange nachgebaut waere
+ es eine Schlange, aus der man nie wieder herauskommt: Jeder
+ Wechsel haenge das eine hinten an und nehme das andere heraus, die
+ gemerkte Stelle ginge dabei jedes Mal verloren. */
+reaktionRouter.post("/workspace/api/reaktion/zweitvideo", nurHost,
+ express.json({ limit: "2kb" }), (req, res) => {
+ try {
+ const roh = String(req.body?.video ?? "").trim();
+ /* Ein leeres Feld raeumt den Platz. Das ist kein Sonderfall,
+ sondern der Normalfall nach einer Sendung. */
+ if (!roh) {
+ db().prepare(`UPDATE reaktion SET zweit_video = '', zweit_titel = '',
+ zweit_sekunde = 0 WHERE id = 1`).run();
+ return res.json(lage(req.person));
+ }
+ const video = videoKennung(roh);
+ if (!video) return res.status(400).json({ fehler: "video_unklar" });
+ /* DASSELBE VIDEO AUF BEIDEN SEITEN WAERE EIN UMSCHALTER OHNE
+ WIRKUNG -- und man sieht es dem Knopf nicht an. */
+ const s = sendung();
+ if (video === s.video) return res.status(409).json({ fehler: "schon_im_bild" });
+
+ db().prepare(`UPDATE reaktion SET zweit_video = ?, zweit_titel = '',
+ zweit_sekunde = 0 WHERE id = 1`).run(video);
+ /* Der Name wird nachgereicht -- wie beim ersten Video auch.
+ Auf YouTube zu warten hiesse, den Knopf haengen zu lassen. */
+ videoTitel(video).then((t) => {
+ try {
+ db().prepare("UPDATE reaktion SET zweit_titel = ? WHERE id = 1 AND zweit_video = ?")
+ .run(t, video);
+ } catch { /* dann steht dort die Kennung, und das ist ehrlich */ }
+ });
+ res.json(lage(req.person));
+ } catch (fehler) {
+ console.error("[reaktion] Zweitvideo:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Hin und her. Beide Seiten behalten ihre Stelle. */
+reaktionRouter.post("/workspace/api/reaktion/tauschen", nurHost,
+ express.json({ limit: "2kb" }), (req, res) => {
+ try {
+ const s = sendung();
+ if (!s.zweit_video) return res.status(409).json({ fehler: "kein_zweites" });
+
+ /* DIE STELLE KOMMT VOM HOST UND NICHT AUS DER DATENBANK.
+ In `reaktion.sekunde` steht der Stand vom letzten Takt -- bis
+ zu fuenf Sekunden alt. Beim Zurueckschalten waeren das jedes
+ Mal fuenf Sekunden, die man zweimal sieht. Wer den Knopf
+ drueckt, hat den Spieler vor sich und weiss es genauer. */
+ const jetztSek = req.body?.sekunde === undefined
+ ? (s.sekunde || 0)
+ : Math.max(0, Number(req.body.sekunde) || 0);
+
+ const neu = s.zweit_video;
+ const neuTitel = s.zweit_titel || "";
+ const neuSek = s.zweit_sekunde || 0;
+
+ /* EIN SCHREIBVORGANG UND NICHT ZWEI. Ein Tausch, der zwischen
+ zwei Anweisungen abbricht, haette dasselbe Video auf beiden
+ Seiten stehen -- und die gemerkte Stelle des anderen waere
+ weg. `node:sqlite` kennt kein `.transaction()`, also von
+ Hand. */
+ db().exec("BEGIN IMMEDIATE");
+ try {
+ db().prepare(`UPDATE reaktion SET
+ video = ?, video_titel = ?, sekunde = ?,
+ zweit_video = ?, zweit_titel = ?, zweit_sekunde = ?,
+ geaendert = ?
+ WHERE id = 1`)
+ .run(neu, neuTitel, neuSek, s.video, s.video_titel || "", jetztSek, jetzt());
+ db().exec("COMMIT");
+ } catch (f) {
+ db().exec("ROLLBACK");
+ throw f;
+ }
+
+ /* Fehlt der Name noch (das Nachreichen von YouTube war nicht
+ fertig), wird er jetzt geholt -- er steht ab sofort im Bild. */
+ if (!neuTitel) {
+ videoTitel(neu).then((t) => {
+ try {
+ db().prepare("UPDATE reaktion SET video_titel = ? WHERE id = 1 AND video = ?")
+ .run(t, neu);
+ } catch { /* dann die Kennung */ }
+ });
+ }
+
+ const n = sendung();
+ melden("reaktion", {
+ was: "video",
+ video: n.video, laeuft: !!n.laeuft, sekunde: n.sekunde,
+ tempo: Number(n.tempo) || 1, gesendet: Date.now(),
+ });
+ melden("reaktion", { was: "getauscht", lage: oeffentlich(lage(req.person)) });
+ res.json(lage(req.person));
+ } catch (fehler) {
+ console.error("[reaktion] Tauschen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
/* ---------- Die Warteschlange ------------------------------------------
Vorher einraeumen, waehrend der Sendung nur weiterschalten.
@@ -1265,7 +1409,11 @@ function oeffentlich(l) {
Listen an jeden im Saal. Wer meldet, muss sich darauf verlassen
koennen, dass das niemand sieht; sonst meldet beim naechsten Mal
keiner mehr. */
- const { zuschauer, ich, liste, meldungen, massnahmen, ...rest } = l;
+ /* `zweit` kam am 28.09. dazu: das zweite, vorbereitete Video. Es
+ ist die Rueckhand fuer den Fall, dass etwas nicht laeuft -- die
+ verraet man nicht vorher, und sie gehoert genau wie die
+ Warteschlange nur denen, die die Sendung fuehren. */
+ const { zuschauer, ich, liste, meldungen, massnahmen, zweit, ...rest } = l;
return rest;
}
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 9cf8b1ac..dec45a4b 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`. -->
-
-
+
+
-
+