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`. --> - - + + - +