diff --git a/assets/img/marke/vanvan-haekelhase.png b/assets/img/marke/vanvan-haekelhase.png new file mode 100644 index 00000000..ab8de79e Binary files /dev/null and b/assets/img/marke/vanvan-haekelhase.png differ diff --git a/server/mess-buehne.mjs b/server/mess-buehne.mjs index fa044be8..3f35c61b 100644 --- a/server/mess-buehne.mjs +++ b/server/mess-buehne.mjs @@ -280,6 +280,14 @@ const fremd = await browser.newContext({ const konsole = []; const tafelSeite = await fremd.newPage(); tafelSeite.on("pageerror", (f) => konsole.push(String(f?.message || f))); +/* DIE ADRESSE GEHOERT IN DIE MELDUNG. Der Browser sagt nur + „Failed to load resource" -- das schickt einen auf die Suche, + ohne zu sagen wonach. Die uebrigen Seiten schreiben den Pfad + seit jeher mit; diese zwei nicht, und genau sie sind die + Quellen, die in den Stream gehen. */ +tafelSeite.on("response", (a) => { + if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`); +}); tafelSeite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); /* Erst der FALSCHE Schluessel. Eine Pruefung, die nur den richtigen @@ -366,6 +374,14 @@ console.log(" Bild: server/mess-buehne-1-tafel.png"); /* ---------- Das Video als Quelle ---------------------------------------- */ const videoSeite = await fremd.newPage(); videoSeite.on("pageerror", (f) => konsole.push(String(f?.message || f))); +/* DIE ADRESSE GEHOERT IN DIE MELDUNG. Der Browser sagt nur + „Failed to load resource" -- das schickt einen auf die Suche, + ohne zu sagen wonach. Die uebrigen Seiten schreiben den Pfad + seit jeher mit; diese zwei nicht, und genau sie sind die + Quellen, die in den Stream gehen. */ +videoSeite.on("response", (a) => { + if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`); +}); await videoSeite.goto(`${BASIS}/workspace/buehne.html?s=${schluessel}`, { waitUntil: "domcontentloaded" }); await videoSeite.waitForTimeout(6000); @@ -399,6 +415,121 @@ if (neuGut.code !== 200) { ausgang = 1; } +/* ---------- Die Werbeeinblendung im Stream ------------------------------ */ +console.log(""); +console.log("=== Die Werbung in der Buehnenquelle ==="); +{ + const setz = (f) => roh("/workspace/api/reaktion/werbung", + { method: "POST", keks: k.admin, rumpf: f }); + + const an = await setz({ art: "laufband", tempo: 12, ecke_dogi: "or", ecke_gross: 1.4 }); + console.log(` Einschalten: ${an.code}`); + if (an.code !== 200) { + console.log(" ACHTUNG: Die Werbung laesst sich nicht einschalten."); + ausgang = 1; + } + + /* DIE QUELLE WIRD NEU GELADEN und nicht nur abgewartet: OBS haelt + sie dauerhaft offen, aber diese Messung soll zuerst beweisen, + dass die Einblendung ueberhaupt ankommt. Ob sie auch WAEHREND + des Laufens nachzieht, ist die zweite Frage -- die steht + darunter. */ + await videoSeite.goto(`${BASIS}/workspace/buehne.html?s=${neuerSchluessel}`, + { waitUntil: "domcontentloaded" }); + await videoSeite.waitForTimeout(2500); + + const w = await videoSeite.evaluate(() => { + const b = document.querySelector(".werbung__band"); + const e = document.querySelector('.werbung__ecke--dogi:not([hidden])'); + const bilder = [...document.querySelectorAll(".werbung img")]; + const rb = b?.getBoundingClientRect(); + const re = e?.getBoundingClientRect(); + return { + band: !!b && !b.hidden && rb.height > 10, + bandHoch: rb ? Math.round(rb.height) : 0, + /* IM BILD, NICHT NUR IM DOKUMENT. Die Quelle ist genau so + gross wie der Stream; was darunter liegt, filmt OBS nicht + mit. */ + bandImBild: !!rb && rb.bottom <= window.innerHeight + 2 && rb.top >= -2, + stuecke: document.querySelectorAll(".werbung__stueck").length, + code: document.querySelector(".werbung__code")?.textContent || "", + ecke: e ? e.dataset.platz : "", + eckeBreit: re ? Math.round(re.width) : 0, + eckeRechtsOben: !!re + && (window.innerWidth - re.right) < re.left && re.top < (window.innerHeight - re.bottom), + /* `naturalWidth` und nicht „das Element ist da": Ein Bild mit + 404 steht genauso im Dokument wie eines, das laedt -- im + Stream ist dort nichts. */ + geladen: bilder.filter((i) => i.naturalWidth > 0).length, + gesamt: bilder.length, + /* DIE SCHRIFT MUSS GROESSER SEIN ALS IM SAAL. Ein Stream wird + auf einem Handy gesehen, oft in einem Viertel des Bildes. + Steht hier dieselbe Groesse wie am Rechner, ist der + Rabattcode nicht zu lesen und die Einblendung umsonst. */ + schrift: Math.round(parseFloat( + getComputedStyle(document.querySelector(".werbung__text") || document.body).fontSize)), + }; + }); + console.log(` Band: ${w.band ? `${w.bandHoch} px` : "FEHLT"}, ${w.stuecke} Stuecke, ` + + `Schrift ${w.schrift} px, Code „${w.code}"`); + console.log(` Ecke: ${w.ecke || "FEHLT"}, ${w.eckeBreit} px, ` + + `rechts oben: ${w.eckeRechtsOben}, Bilder ${w.geladen}/${w.gesamt}`); + if (!w.band || !w.bandImBild || w.stuecke < 2) { + console.log(" ACHTUNG: Im Stream laeuft kein Band."); + ausgang = 1; + } + if (!w.code) { + console.log(" ACHTUNG: Der Rabattcode ist im Stream nicht hervorgehoben."); + ausgang = 1; + } + if (w.ecke !== "or" || !w.eckeRechtsOben) { + console.log(" ACHTUNG: Das Logo steht im Stream nicht oben rechts."); + ausgang = 1; + } + if (!w.gesamt || w.geladen < w.gesamt) { + console.log(` ACHTUNG: ${w.gesamt - w.geladen} Bild(er) laden im Stream nicht.`); + ausgang = 1; + } + if (w.schrift < 20) { + console.log(` ACHTUNG: ${w.schrift} px Schrift ist fuer einen Stream zu klein.`); + ausgang = 1; + } + await videoSeite.screenshot({ path: join(HIER, "mess-buehne-4-werbung.png") }); + console.log(" Bild: server/mess-buehne-4-werbung.png"); + + /* ---- Zieht die OFFENE Quelle nach? ----------------------------- + Das ist die eigentliche Betriebsfrage. OBS laedt diese Seite + einmal beim Start der Szene und danach nie wieder -- wenn + Filipe mitten in der Sendung umschaltet und die Quelle das + nicht mitbekommt, wirbt der Stream bis zum naechsten Neustart + weiter fuer das Falsche. */ + await setz({ ecke_dogi: "ul" }); + await videoSeite.waitForTimeout(2500); + const nach = await videoSeite.evaluate(() => { + const e = document.querySelector('.werbung__ecke--dogi:not([hidden])'); + return e ? e.dataset.platz : ""; + }); + console.log(` Umgeschaltet auf „unten links", die offene Quelle zeigt: „${nach || "nichts"}"`); + if (nach !== "ul") { + console.log(" ACHTUNG: Die offene Buehnenquelle zieht eine Aenderung nicht nach."); + ausgang = 1; + } + + /* ---- Und wieder aus ---- */ + await setz({ art: "aus", ecke_dogi: "aus", ecke_hase: "aus" }); + await videoSeite.waitForTimeout(2000); + const weg = await videoSeite.evaluate(() => ({ + band: !!document.querySelector(".werbung__band:not([hidden])"), + ecken: document.querySelectorAll(".werbung__ecke:not([hidden])").length, + })); + console.log(` Abgeschaltet: Band ${weg.band ? "STEHT NOCH" : "weg"}, ` + + `${weg.ecken} Ecken uebrig`); + if (weg.band || weg.ecken) { + console.log(" ACHTUNG: Abgeschaltete Werbung bleibt im Stream stehen."); + ausgang = 1; + } +} + /* ---------- Der Buehnenmodus der Reaction-Seite -------------------------- */ const hostSeite = await alsWer("admin", 1280, 720); await hostSeite.seite.goto(`${BASIS}/workspace/reaktion.html?nur=buehne`, @@ -430,7 +561,51 @@ if (nurBild.kopf || nurBild.chat || nurBild.pult || nurBild.schild || !nurBild.l await hostSeite.seite.screenshot({ path: join(HIER, "mess-buehne-3-modus.png") }); console.log(" Bild: server/mess-buehne-3-modus.png"); -if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 4).join(" | ")); +/* ---------- Was in den Quellen schiefging ------------------------------- + ZWEI FEHLSCHLAEGE SIND BESTELLT und stehen deshalb nicht in der + Meldung: der Abruf mit dem absichtlich falschen Schluessel und der + Nachfass der offenen Quelle auf den eben abgeloesten alten. Beide + sind das Gelingen einer Pruefung, nicht ihr Scheitern. + + Sie standen bisher mit drin. Eine Meldung, die IMMER vier Zeilen + zeigt, wird nach dem zweiten Mal ueberlesen -- und ein echter 404 + in genau der Seite, die in den Stream geht, faellt darin nicht + auf. Deshalb eng gefiltert, benannt, und der Rest ungekuerzt. */ +const bestellt = [ + (z) => z.includes("s=falschfalschfalsch"), + (z) => z.includes("/buehne/strom?s=") && z.includes(schluessel), + /* Dieselben Ereignisse ohne Adresse -- die Adresse steht seit + heute in der Zeile daneben. */ + (z) => z.startsWith("Failed to load resource"), +]; +const echt = konsole.filter((z) => !bestellt.some((f) => f(z))); + +/* GEGENPROBE: Ein Filter, der alles durchlaesst, waere bequem und + wertlos. Diese vier Proben beweisen, dass er beide Richtungen + kann -- er nimmt die bestellten Faelle heraus UND laesst einen + echten 404 stehen, auch einen auf ein Werbebild. */ +{ + const proben = [ + ["404 /workspace/api/buehne/lage?s=falschfalschfalsch", false], + [`404 /workspace/api/buehne/strom?s=${schluessel}`, false], + ["404 /workspace/assets/img/werbung-hase.webp", true], + ["TypeError: x ist nicht definiert", true], + ]; + const daneben = proben.filter(([z, soll]) => + (!bestellt.some((f) => f(z))) !== soll); + if (daneben.length) { + console.log(` ACHTUNG: Der Konsolenfilter ordnet ${daneben.length} Probe(n) falsch ein: ` + + daneben.map(([z]) => z.slice(0, 40)).join(" | ")); + ausgang = 1; + } +} +if (echt.length) { + console.log(` KONSOLE: ${echt.length} unerwartete(r) Fehlschlag/Fehlschlaege`); + for (const z of echt.slice(0, 6)) console.log(` ${z}`); + ausgang = 1; +} else { + console.log(` Konsole sauber (${konsole.length} bestellte Fehlschlaege gefiltert).`); +} await fremd.close(); await hostSeite.kontext.close(); diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 05c5f344..0ff25e9f 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -2372,6 +2372,234 @@ if (!rausgesetzt.da || !rausgesetzt.markiert await bild(zuschauer.seite, "14-aus-der-sendung"); await roh(`/workspace/api/reaktion/massnahme/${IDS.gast}`, { method: "DELETE", keks: k.admin }); +/* ===================================================================== + DIE WERBEEINBLENDUNG -- beim Zuschauer, nicht beim Host + ===================================================================== */ +console.log(""); +console.log("=== Die Werbeeinblendung ==="); +{ + /* DER ZUSCHAUER WAR EBEN NOCH AUS DER SENDUNG GENOMMEN, und die + Massnahme ist zurueckgenommen -- der Vorhang steht trotzdem + noch. Das ist Absicht und kein Fehler: `rausgenommen()` + schliesst den Ereignisstrom, damit niemand die Sendung + weiterverfolgt, waehrend er draussen ist. Genau deshalb kommt + die Ruecknahme dort nicht mehr an. + + Gemessen wird deshalb nach einem Neuladen -- das ist auch das, + was ein Mensch taete. Dass der Vorhang OHNE Neuladen stehen + bleibt, steht als offener Punkt in der Liste; hier still darum + herumzumessen waere das Verstecken eines Befunds. */ + await zuschauer.seite.reload({ waitUntil: "domcontentloaded" }); + await zuschauer.seite.waitForFunction( + () => !document.querySelector(".raus__titel") + && !!document.getElementById("leinwand"), + null, { timeout: 15000 }); + await zuschauer.seite.waitForTimeout(600); + + const setz = (felder) => host.seite.evaluate(async (f) => { + const a = await fetch("/workspace/api/reaktion/werbung", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(f), + }); + return { code: a.status, text: (await a.text()).slice(0, 120) }; + }, felder); + + /* ---- 1. Das Laufband ---- */ + const an = await setz({ art: "laufband", tempo: 12 }); + console.log(` Band einschalten: ${an.code}`); + await host.seite.waitForTimeout(700); + await zuschauer.seite.waitForTimeout(900); + + const band = await zuschauer.seite.evaluate(() => { + const w = document.querySelector(".werbung"); + const b = document.querySelector(".werbung__band"); + if (!w || !b) return null; + const r = b.getBoundingClientRect(); + const lein = document.getElementById("leinwand")?.getBoundingClientRect(); + const stuecke = [...document.querySelectorAll(".werbung__stueck")]; + const bilder = [...document.querySelectorAll(".werbung__stueck img")]; + return { + art: w.dataset.art, + sichtbar: !b.hidden && r.width > 10 && r.height > 10, + hoch: Math.round(r.height), + /* STEHT ES IM BILD? Ein Band, das rechnerisch existiert und + unter dem Rand der Leinwand liegt, ist kein Band. */ + imBild: !!lein && r.bottom <= lein.bottom + 2 && r.top >= lein.top - 2, + stuecke: stuecke.length, + /* `naturalWidth` und nicht „gibt es das Element": Ein Bild, + das 404 antwortet, steht genauso im Dokument wie eines, + das laedt -- auf dem Bildschirm ist dort nichts. */ + bilderGeladen: bilder.filter((i) => i.naturalWidth > 0).length, + bilderGesamt: bilder.length, + /* ZEILE UND UNTERZEILE GETRENNT ABLESEN. `textContent` des + ganzen Stuecks klebt beides aneinander + („dogfather-universe.comAlles ueber das Rudel") -- im Bild + stehen sie untereinander, im Protokoll sah es nach einem + fehlenden Leerzeichen aus. Eine Messung, die einen Fehler + ERFINDET, kostet genauso viel Zeit wie eine, die einen + verschweigt. */ + texte: stuecke.slice(0, 2).map((z) => [ + z.querySelector(".werbung__text")?.textContent.trim() || "", + z.querySelector(".werbung__unter")?.textContent.trim() || "", + ].filter(Boolean).join(" / ")), + code: document.querySelector(".werbung__code")?.textContent || "", + }; + }); + if (!band) { + console.log(" ACHTUNG: beim Zuschauer gibt es gar keine Einblendung."); + ausgang = 1; + } else { + console.log(` Band: ${band.art}, ${band.hoch} px hoch, ${band.stuecke} Stuecke, ` + + `Bilder ${band.bilderGeladen}/${band.bilderGesamt}`); + console.log(` Texte: ${band.texte.join(" | ")}`); + console.log(` Rabattcode hervorgehoben: „${band.code}"`); + if (!band.sichtbar) { console.log(" ACHTUNG: das Band ist nicht zu sehen."); ausgang = 1; } + if (!band.imBild) { console.log(" ACHTUNG: das Band liegt ausserhalb der Leinwand."); ausgang = 1; } + if (band.bilderGesamt && band.bilderGeladen < band.bilderGesamt) { + console.log(" ACHTUNG: ein Bild im Band laedt nicht."); + ausgang = 1; + } + /* DAS LAUFBAND TRAEGT ZWEIMAL DIESELBE FOLGE. Ohne die zweite + entstuende am Ende jedes Durchlaufs eine leere Flaeche -- und + das sieht aus, als sei die Einblendung abgestuerzt. */ + if (band.stuecke !== 4) { + console.log(` ACHTUNG: zwei Botschaften, zweimal -- erwartet 4 Stuecke, gezaehlt ${band.stuecke}.`); + ausgang = 1; + } + if (!band.code) { + console.log(" ACHTUNG: DOGI10 wurde nicht als Code erkannt."); + ausgang = 1; + } + } + + /* ---- 2. Die Ecken ---- */ + const ecken = await setz({ ecke_dogi: "ol", ecke_hase: "ur", ecke_gross: 1.4 }); + console.log(` Ecken setzen: ${ecken.code}`); + await zuschauer.seite.waitForTimeout(800); + + const lage = await zuschauer.seite.evaluate(() => { + const lein = document.getElementById("leinwand")?.getBoundingClientRect(); + const lies = (welches) => { + const e = document.querySelector(`.werbung__ecke--${welches}`); + if (!e || e.hidden) return null; + const r = e.getBoundingClientRect(); + const bild = e.querySelector("img"); + return { + platz: e.dataset.platz, + breit: Math.round(r.width), + geladen: !!bild && bild.naturalWidth > 0, + /* GEMESSEN WIRD DER ABSTAND ZU DEN VIER KANTEN, nicht der + Name der Ecke. Eine Pruefung, die nur ihren eigenen + Namen bestaetigt, bestaetigt nichts. */ + l: Math.round(r.left - lein.left), r: Math.round(lein.right - r.right), + o: Math.round(r.top - lein.top), u: Math.round(lein.bottom - r.bottom), + }; + }; + return { dogi: lies("dogi"), hase: lies("hase") }; + }); + for (const [welches, soll] of [["dogi", "ol"], ["hase", "ur"]]) { + const e = lage[welches]; + if (!e) { console.log(` ACHTUNG: ${welches} steht nicht im Bild.`); ausgang = 1; continue; } + console.log(` ${welches}: ${e.platz}, ${e.breit} px, ` + + `Abstaende l/r/o/u ${e.l}/${e.r}/${e.o}/${e.u}, Bild ${e.geladen ? "laedt" : "FEHLT"}`); + if (!e.geladen) { console.log(` ACHTUNG: das Bild von ${welches} laedt nicht.`); ausgang = 1; } + const linksOben = e.l < e.r && e.o < e.u; + const rechtsUnten = e.r < e.l && e.u < e.o; + if (soll === "ol" && !linksOben) { + console.log(" ACHTUNG: dogi sollte oben links stehen."); ausgang = 1; + } + if (soll === "ur" && !rechtsUnten) { + console.log(" ACHTUNG: hase sollte unten rechts stehen."); ausgang = 1; + } + } + + /* ---- 3. Zwei Bilder auf denselben Platz ---- */ + const doppelt = await setz({ ecke_hase: "ol" }); + console.log(` Beide auf denselben Platz: ${doppelt.code} ` + + `(400 erwartet -- ein Bild auf dem anderen ist keine Absicht)`); + if (doppelt.code !== 400) { + console.log(" ACHTUNG: zwei Bilder duerfen nicht auf demselben Platz landen."); + ausgang = 1; + } + + /* ---- 4. Der Wechsel ---- */ + await setz({ art: "wechsel", tempo: 8 }); + await zuschauer.seite.waitForTimeout(900); + const wechsel = await zuschauer.seite.evaluate(() => { + const w = document.querySelector(".werbung"); + const alle = [...document.querySelectorAll(".werbung__stueck--wechsel")]; + const sichtbar = alle.filter((z) => z.dataset.dran === "ja"); + return { + art: w?.dataset.art, + wieviele: alle.length, + dran: sichtbar.length, + /* UEBEREINANDER, NICHT NACHEINANDER -- gefragt ist, ob die + Hoehe des Bandes springt, wenn eine laengere Botschaft + kommt. Deshalb wird die HOEHE gemessen und keine + Koordinate: Liegen die Botschaften in derselben + Gitterzelle, ist ihr Traeger so hoch wie die hoechste von + ihnen; liegen sie untereinander, so hoch wie ihre Summe. + + Zwei frueherre Fassungen haben hier Oberkanten und Mitten + verglichen und beide Male die ANIMATION abgelesen: Die + ruhende Botschaft steht um genau die sechs Pixel tiefer, + mit denen sie hereingleitet. Eine Hoehe kennt kein + `translateY`. */ + traeger: (() => { + const l = document.querySelector(".werbung__lauf"); + return l ? Math.round(l.getBoundingClientRect().height) : 0; + })(), + hoehen: alle.map((z) => Math.round(z.getBoundingClientRect().height)), + }; + }); + { + const h = wechsel.hoehen; + const summe = h.reduce((a, b) => a + b, 0); + const hoechste = Math.max(0, ...h); + /* `summe > hoechste + 10` ist die Gegenprobe: Gaebe es nur eine + Botschaft, waere „der Traeger ist so hoch wie die hoechste" + zwangslaeufig erfuellt -- gruen, ohne etwas ueber das + Uebereinanderliegen zu wissen. */ + wechsel.summe = summe; + wechsel.hoechste = hoechste; + wechsel.gleicheStelle = summe > hoechste + 10 + && wechsel.traeger <= hoechste + 2; + } + console.log(` Wechsel: ${wechsel.wieviele} Botschaften, ${wechsel.dran} sichtbar, ` + + `Traeger ${wechsel.traeger} px bei Hoehen ${wechsel.hoehen.join("+")} ` + + `(uebereinander ${wechsel.hoechste}, untereinander ${wechsel.summe}), ` + + `uebereinander: ${wechsel.gleicheStelle}`); + if (wechsel.art !== "wechsel" || wechsel.dran !== 1) { + console.log(" ACHTUNG: beim Wechsel muss genau eine Botschaft dastehen."); + ausgang = 1; + } + if (!wechsel.gleicheStelle) { + console.log(" ACHTUNG: die Botschaften liegen nicht uebereinander -- die Hoehe springt."); + ausgang = 1; + } + + /* ---- 5. Ein Bild davon ---- */ + await setz({ art: "laufband", ecke_hase: "ur" }); + await zuschauer.seite.waitForTimeout(900); + await bild(zuschauer.seite, "24-werbung"); + + /* ---- 6. Wieder aus ---- */ + const aus = await setz({ art: "aus", ecke_dogi: "aus", ecke_hase: "aus" }); + await zuschauer.seite.waitForTimeout(700); + const weg = await zuschauer.seite.evaluate(() => { + const b = document.querySelector(".werbung__band"); + const e = [...document.querySelectorAll(".werbung__ecke")].filter((x) => !x.hidden); + return { band: !!b && !b.hidden, ecken: e.length }; + }); + console.log(` Nach dem Abschalten: Band ${weg.band ? "STEHT NOCH" : "weg"}, ` + + `${weg.ecken} Ecken uebrig`); + if (weg.band || weg.ecken) { + console.log(" ACHTUNG: abgeschaltete Werbung steht weiter im Bild."); + ausgang = 1; + } +} + await modi.kontext.close(); await host.kontext.close(); await gast2.kontext.close(); @@ -2397,6 +2625,12 @@ await zuschauer.kontext.close(); hoch: Math.round(le.getBoundingClientRect().height), zeilen: new Set(k.map((x) => Math.round(x.getBoundingClientRect().top))).size, rollt: r.scrollWidth > r.clientWidth + 1, + /* WIE VIELE ES GIBT, ZAEHLT DIE SEITE SELBST. Hier stand + frueher die feste Zahl 7; als ein achtes Register dazukam, + meldete die Pruefung „nur 8 von 7". Eine Zahl, die jemand + abschreibt, veraltet in dem Moment, in dem jemand anders + weiterbaut. */ + wieviele: k.length, sichtbar: k.filter((x) => { const q = x.getBoundingClientRect(); return q.left >= rr.left - 1 && q.right <= rr.right + 1; @@ -2426,7 +2660,8 @@ await zuschauer.kontext.close(); ausgang = 1; } else { console.log(` Handy-Regie: ${l.hoch} px, ${l.zeilen} Zeile(n), rollt=${l.rollt}, ` - + `${l.sichtbar} von 7 im Bild, Schild=${l.schild}, seitlich ${l.ueber} px`); + + `${l.sichtbar} von ${l.wieviele} im Bild, Schild=${l.schild}, ` + + `seitlich ${l.ueber} px`); if (l.ueber > 0) console.log(` ueber die Kante: ${l.wer}`); /* SIE BRICHT UM, SEIT DEM 28.09.2026 -- UND DAS IST DER SINN. Vorher verlangte diese Stelle `rollt === true` und `zeilen @@ -2438,10 +2673,16 @@ await zuschauer.kontext.close(); swippen müssen." Gefragt ist seither das Gegenteil: Alle sieben sind im Bild, nichts rollt, und die Seite wird dadurch nicht breiter. */ - if (l.rollt || l.sichtbar !== 7 || l.ueber > 0 || l.schild) { + /* `wieviele >= 7` ist keine zweite feste Zahl, sondern die + Untergrenze gegen den leeren Fall: Faende die Messung gar + keine Register, waere `sichtbar === wieviele` mit 0 === 0 + erfuellt und die Pruefung gruen, ohne etwas angesehen zu + haben. */ + if (l.rollt || l.wieviele < 7 || l.sichtbar !== l.wieviele + || l.ueber > 0 || l.schild) { console.log(` ACHTUNG: Die Regieleiste am Handy rollt (${l.rollt}), ` - + `zeigt nur ${l.sichtbar} von 7 Registern oder schiebt die Seite ` - + `${l.ueber} px breiter.`); + + `zeigt nur ${l.sichtbar} von ${l.wieviele} Registern oder schiebt ` + + `die Seite ${l.ueber} px breiter.`); ausgang = 1; } } @@ -2568,5 +2809,6 @@ if (offenePunkte.length) { console.log(`\n=== ${offenePunkte.length} bekannte(r) offene(r) Punkt(e) ===`); for (const z of offenePunkte) console.log(` OFFEN: ${z}`); } + console.log("\nFertig."); process.exit(ausgang); diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs index 95e5fc26..ee017ce2 100644 --- a/server/pruef-buehne.mjs +++ b/server/pruef-buehne.mjs @@ -175,10 +175,34 @@ melde("=== 2. Der Schluessel ==="); /* `layout` kam am 28.09. dazu: Bei „nur Kamera" muss die Quelle das Bild abschalten, sonst liegt im Stream ein stehengebliebenes YouTube-Bild unter den Kameras. */ + /* `werbung` kam am 29.09. dazu: Band und Eckenbilder gehoeren in + den Stream, sonst sieht der Zuschauer im Saal eine Einblendung, + die auf TikTok fehlt. */ const erlaubt = ["gesendet", "laeuft", "layout", "sekunde", "stand", "tempo", - "titel", "video", "video_titel"]; + "titel", "video", "video_titel", "werbung"]; ok(JSON.stringify(felder) === JSON.stringify(erlaubt), `die Auskunft hat genau die erlaubten Felder (${felder.join(", ")})`); + + /* UND EINE EBENE TIEFER, AUS DEMSELBEN GRUND. Eine abschliessende + Liste, die nur die oberste Ebene kennt, laesst sich umgehen, + indem man ein Feld in ein vorhandenes Objekt legt -- ab da + waechst dort alles unbemerkt mit. `werbung` ist das erste + verschachtelte Feld dieser Auskunft; ohne diese Pruefung waere + die Liste darueber ab heute nur noch halb wirksam. */ + const wf = Object.keys(gut.j?.werbung || {}).sort(); + const wErlaubt = ["art", "band", "ecke_dogi", "ecke_gross", "ecke_hase", "tempo"]; + ok(JSON.stringify(wf) === JSON.stringify(wErlaubt), + `auch die Werbung hat genau ihre Felder (${wf.join(", ") || "keine"})`); + + /* Und was im Band steht, ist Text -- keine Person, keine Kennung. + Ein Feld namens `person_id` faellt hier auf, bevor es im Stream + steht. */ + const bandFelder = [...new Set((gut.j?.werbung?.band || []) + .flatMap((b) => Object.keys(b)))].sort(); + const bErlaubt = ["bild", "text", "unterzeile"]; + ok(JSON.stringify(bandFelder) === JSON.stringify(bErlaubt) + || bandFelder.length === 0, + `die Botschaften tragen nur Text (${bandFelder.join(", ") || "keine"})`); for (const verboten of ["zuschauer", "dabei", "gaeste", "chat", "host", "ich", "liste"]) { ok(!(verboten in (gut.j || {})), `kein „${verboten}" darin`); } diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs index c0b90efd..134d489c 100644 --- a/server/pruef-struktur.mjs +++ b/server/pruef-struktur.mjs @@ -489,7 +489,19 @@ const BEKANNT_TOT = [".content-wurzel", ".code-feld"]; const cssOrdner = join(WS, "assets", "css"); const jsOrdner = join(WS, "assets", "js"); let quelle = ""; - for (const n of seiten) quelle += readFileSync(join(WS, n), "utf8"); + /* HIER `alleSeiten` UND NICHT `seiten` (29.09.2026). + + `seiten` laesst `buehne.html` und `tafel.html` aus -- mit gutem + Grund, aber fuer eine ANDERE Frage: Die beiden brauchen kein + Startbildschirm-Symbol und kein Manifest. Beim Suchen nach totem + CSS hat dieselbe Ausnahme eine falsche Antwort gegeben: Eine + Klasse, die nur in einer OBS-Quelle steht, galt als unbenutzt. + + Aufgefallen an `.obs-buehne`, das genau dort steht. Der Wachter + haette also verlangt, eine Regel zu loeschen, die den Stream + traegt. Eine Ausnahme wirkt immer auf ALLES, was hinter ihr + kommt -- nicht nur auf die Frage, fuer die sie gemacht wurde. */ + for (const n of alleSeiten) quelle += readFileSync(join(WS, n), "utf8"); for (const n of readdirSync(jsOrdner).filter((x) => x.endsWith(".js"))) { quelle += readFileSync(join(jsOrdner, n), "utf8"); } diff --git a/server/pruef-tippziele.mjs b/server/pruef-tippziele.mjs index 90240535..1b766f92 100644 --- a/server/pruef-tippziele.mjs +++ b/server/pruef-tippziele.mjs @@ -148,13 +148,29 @@ for (const r of alle) { /* ---- 2. Wird es auf Fingergeräten angehoben? ----------------------- */ /** Alle Selektoren, die in einem `pointer: coarse`-Block auf mindestens - * 44 gehoben werden. Abgeleitet, nicht abgeschrieben. */ + * 44 gehoben werden. Abgeleitet, nicht abgeschrieben. + * + * FESTE GROESSEN ZAEHLEN MIT (29.09.2026). Bis dahin galt nur + * `min-height`/`min-width` als Anhebung. Ein `height: 44px` in + * einem Fingerblock hebt den Knopf aber genauso -- er wurde + * trotzdem als ungedeckt gemeldet. Ein Fehlalarm an einer Pruefung, + * die nach jeder Aenderung laeuft, wird weggeklickt, und mit ihm + * der echte Befund, der eines Tages danebensteht. + * + * ENG GEFASST: Eine feste Angabe deckt nur, wenn die KLEINERE der + * beiden Seiten 44 erreicht. `width: 44px; height: 20px` ist ein + * Streifen, kein Tippziel. Fehlt eine Seite ganz, bleibt sie ausser + * Betracht -- sie wird dann vom Inhalt bestimmt und ist nicht + * nachweisbar klein. */ const gehoben = new Set(); for (const r of alle) { if (!r.media || !/pointer:\s*coarse/.test(r.media)) continue; const minH = /min-height:\s*(\d+)px/.exec(r.koerper); const minW = /min-width:\s*(\d+)px/.exec(r.koerper); - if (!minH && !minW) continue; + const fest = festeGroesse(r.koerper); + const festDeckt = (fest.h !== null || fest.w !== null) + && Math.min(fest.h ?? Infinity, fest.w ?? Infinity) >= ZIEL; + if (!minH && !minW && !festDeckt) continue; for (const teil of r.sel.split(",")) { const t = teil.trim(); if (!t) continue; @@ -246,6 +262,19 @@ pruef("Gegenprobe: eine feste Größe wird erkannt", pruef("Gegenprobe: eine min-Größe ist KEINE feste Größe", festeGroesse("min-width: 34px;").w === null, "sonst gälte ein Knopf, der nach unten begrenzt ist, als zu klein"); +pruef("Gegenprobe: eine feste 44er-Größe im Fingerblock deckt", + (() => { + const f = festeGroesse("width: 58px; height: 44px;"); + return Math.min(f.h ?? Infinity, f.w ?? Infinity) >= ZIEL; + })(), + "sonst wäre jedes mit `height` angehobene Bedienelement ein Fehlalarm"); +pruef("Gegenprobe: ein 44 breiter STREIFEN deckt nicht", + (() => { + /* 44 breit, 20 hoch. Wer nur eine der beiden Seiten prüft, + hielte das für ein Tippziel -- es ist eine Leiste. */ + const f = festeGroesse("width: 44px; height: 20px;"); + return Math.min(f.h ?? Infinity, f.w ?? Infinity) < ZIEL; + })()); pruef("Gegenprobe: eine zu kleine Trefferfläche rettet nichts", (() => { /* 24 + 2x5 = 34 -- das reicht nicht, und die Prüfung muss das diff --git a/server/reaktion-tabellen.js b/server/reaktion-tabellen.js index 53a53f54..0a2c6611 100644 --- a/server/reaktion-tabellen.js +++ b/server/reaktion-tabellen.js @@ -186,6 +186,91 @@ export const CHAT_MODI = ["offen", "team", "zu"]; dauert, ist ein ungeprueftes Verhalten. */ export const LISTE_MAX = Number(process.env.REAKTION_LISTE_MAX || 30); +/* ==== DIE WERBEEINBLENDUNG (29.09.2026) ========================== + + WIE DAS BAND LAEUFT. Drei Arten, und jede hat ihren Fall: + + aus nichts. Die Vorgabe -- ein Band, das immer laeuft, + liest nach zehn Minuten niemand mehr. + laufband der Text zieht von rechts nach links durch. Gut fuer + mehrere Botschaften hintereinander, ohne Sprung. + wechsel eine Botschaft steht, blendet aus, die naechste + kommt. Besser lesbar -- ein Rabattcode, den man + abschreiben will, darf nicht wandern. + + ZWEI ARTEN STATT EINER: Ein Laufband ist schoen fuer Aufmerksamkeit + und schlecht zum Merken. „DOGI10" hinterherzulesen, waehrend es + nach links wandert, ist genau das, woran Rabattcodes scheitern. */ +export const BANNER_ARTEN = ["aus", "laufband", "wechsel"]; + +/* Sekunden. Beim Laufband: fuer einen ganzen Durchlauf. Beim + Wechsel: wie lange eine Botschaft steht. + + NICHT UNTER SECHS. Gemessen an einem Satz von 60 Zeichen braucht + ein Mensch rund vier Sekunden zum Lesen, und eine Botschaft, die + verschwindet, bevor man sie gelesen hat, ist schlimmer als keine: + Sie nimmt Platz und gibt nichts. */ +export const BANNER_TEMPI = [8, 12, 16, 24, 40]; + +/* WIE VIELE BOTSCHAFTEN. Acht ist keine gerundete Zahl: Bei + zwoelf Sekunden je Botschaft waere ein voller Umlauf sonst + laenger als eine Werbepause -- niemand sieht die letzte. */ +export const BANNER_MAX = 8; +export const BANNER_TEXT_MAX = 90; +export const BANNER_UNTER_MAX = 60; + +/* WO EIN BILD STEHEN KANN. Sechs Plaetze und „aus". + + Filipe nennt sie selbst: „oben rechts links unten rechts links + genau wie oben unten mittig". Die Mitte links und rechts fehlt + mit Absicht -- dort liegen bei jedem Videodienst die + Bedienelemente, und ein Wasserzeichen auf dem Pausenknopf ist + ein Wasserzeichen, das man wegklickt. */ +export const ECKEN_PLAETZE = ["aus", "ol", "om", "or", "ul", "um", "ur"]; +export const ECKEN_WORT = { + aus: "aus", ol: "oben links", om: "oben mittig", or: "oben rechts", + ul: "unten links", um: "unten mittig", ur: "unten rechts", +}; + +/* Wie gross das Bild in der Ecke steht. 1 sind rund 110 Pixel auf + der Leinwand. Darunter erkennt man den Husky nicht mehr als + Husky, darueber deckt er im Hochformat ein Achtel des Bildes. */ +export const ECKEN_GROESSEN = [0.7, 1, 1.4, 2]; + +/* Die zwei Bilder. Sie liegen unter `workspace/assets/img/` und + werden OHNE ANMELDUNG ausgeliefert -- die OBS-Quelle hat keine. */ +export const BANNER_BILDER = ["keins", "dogi", "hase"]; + +/* ==== DER STAND DER WERBEEINBLENDUNG =============================== + + AN EINER STELLE GERECHNET, weil ihn ZWEI Haeuser brauchen: der + Saal (`workspace-reaktion.js`) und die Buehnenquelle, die OBS + abfilmt (`workspace-buehne.js`). Beide hatten die Abfrage erst + abgeschrieben. Als die Datenbank-Kennung aus dem Band + verschwinden sollte, habe ich eine der beiden geaendert -- und + die andere lieferte sie weiter. + + OHNE `id`. Dieser Stand geht auch an die Buehnenquelle, und die + ist nur mit einem Schluessel geschuetzt, nicht mit einer + Anmeldung; alles darin ist damit faktisch oeffentlich. Die + Anzeige zeichnet Text und braucht keine Kennung. + + Die Datenbank kommt herein und wird nicht geholt: Diese Datei + importiert nichts, und das soll sie auch nicht -- `workspace.js` + importiert umgekehrt hierher. */ +export function werbungStand(d) { + const s = d.prepare("SELECT * FROM reaktion WHERE id = 1").get(); + return { + art: s?.banner_art || "aus", + tempo: Number(s?.banner_tempo) || 16, + ecke_dogi: s?.ecke_dogi || "aus", + ecke_hase: s?.ecke_hase || "aus", + ecke_gross: Number(s?.ecke_gross) || 1, + band: d.prepare(`SELECT text, unterzeile, bild FROM reaktion_banner + WHERE aktiv = 1 ORDER BY reihung, id`).all(), + }; +} + export function reaktionTabellen(d) { d.exec(` /* Genau EINE Zeile. Die Sperre steht in der Tabelle und nicht im @@ -316,6 +401,24 @@ export function reaktionTabellen(d) { YouTube erfragt. Sonst haengt die Anzeige der Liste an einem fremden Dienst: Ist er langsam, laedt das Pult langsam; ist er weg, steht dort nichts. Einmal holen, hinlegen, fertig. */ + /* ==== DIE BOTSCHAFTEN DES BANDES ========================== + Eine Zeile je Botschaft. Sie gehoeren NICHT an die Sendung: + Wer sie einmal eintraegt, will sie beim naechsten Mal + wiederhaben -- eine Adresse und ein Rabattcode aendern sich + seltener als das Video. */ + CREATE TABLE IF NOT EXISTS reaktion_banner ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + unterzeile TEXT NOT NULL DEFAULT '', + bild TEXT NOT NULL DEFAULT 'keins' + CHECK (bild IN ('keins','dogi','hase')), + aktiv INTEGER NOT NULL DEFAULT 1, + reihung INTEGER NOT NULL DEFAULT 0, + erstellt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_reaktion_banner_reihung + ON reaktion_banner (aktiv, reihung, id); + CREATE TABLE IF NOT EXISTS reaktion_liste ( id INTEGER PRIMARY KEY AUTOINCREMENT, video TEXT NOT NULL, @@ -342,6 +445,33 @@ export function reaktionTabellen(d) { (id, stand, stand_seit, geaendert) VALUES (1, 'zu', ?, ?)`) .run(new Date().toISOString(), new Date().toISOString()); + /* ==== ZWEI BOTSCHAFTEN ZUM ANFANGEN ========================== + Genau die zwei, die Filipe genannt hat. Ein leeres Band waere + zwar ehrlich, aber er muesste vor der ersten Sendung tippen -- + und das ist der Moment, in dem man es laesst. + + `INSERT ... WHERE NOT EXISTS` und nicht „wenn die Tabelle leer + ist": Wer sie alle loescht, will sie los sein. Sie kommen nur + beim allerersten Mal. */ + { + const schon = d.prepare("SELECT COUNT(*) AS n FROM reaktion_banner").get().n; + const jeGab = d.prepare( + "SELECT wert FROM einstellungen WHERE schluessel = 'reaktion_banner_vorgabe'").get(); + if (schon === 0 && !jeGab) { + const jetzt = new Date().toISOString(); + const setz = d.prepare(`INSERT INTO reaktion_banner + (text, unterzeile, bild, aktiv, reihung, erstellt) VALUES (?,?,?,1,?,?)`); + setz.run("dogfather-universe.com", + "Alles \u00fcber das Rudel an einem Ort", "dogi", 0, jetzt); + setz.run("vans-diy-bastelbedarf.com", + "Rabattcode DOGI10 \u2014 H\u00e4kelbedarf von VanVan", "hase", 1, jetzt); + d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von) + VALUES ('reaktion_banner_vorgabe', ?, ?, NULL) + ON CONFLICT(schluessel) DO NOTHING`).run(jetzt, jetzt); + console.log("[reaktion] Zwei Werbebotschaften angelegt."); + } + } + /* ==== NACHGEWACHSENE SPALTEN ==================================== `ALTER TABLE ... ADD COLUMN` und NICHT „Tabelle neu bauen und @@ -352,12 +482,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)); + /* ==== DIE WERBUNG HAENGT AN DER SENDUNG =================== + Art, Tempo und die zwei Ecken gelten fuer alle, die zusehen -- + also gehoeren sie an die eine Zeile der Sendung und nicht an + jede Person. Wer sie je Browser setzte, haette ein Bild, ueber + das er redet, und ein anderes, das die Leute sehen. */ + const werbungSpalten = [ + ["banner_art", "TEXT NOT NULL DEFAULT 'aus'"], + ["banner_tempo", "INTEGER NOT NULL DEFAULT 16"], + ["ecke_dogi", "TEXT NOT NULL DEFAULT 'aus'"], + ["ecke_hase", "TEXT NOT NULL DEFAULT 'aus'"], + ["ecke_gross", "REAL NOT NULL DEFAULT 1"], + ]; + 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."); } + for (const [name, form] of werbungSpalten) { + if (da.has(name)) continue; + d.exec(`ALTER TABLE reaktion ADD COLUMN ${name} ${form}`); + console.log(`[reaktion] Spalte ${name} angelegt.`); + } + const dabei = new Set( d.prepare("PRAGMA table_info(reaktion_dabei)").all().map((z) => z.name)); if (!dabei.has("bildschirm")) { diff --git a/server/workspace-buehne.js b/server/workspace-buehne.js index 13f57333..6a6e6561 100644 --- a/server/workspace-buehne.js +++ b/server/workspace-buehne.js @@ -59,6 +59,10 @@ import { existsSync, createReadStream } from "node:fs"; import { db, sitzungLesen, einstellung, einstellungSetzen, protokolliere, echteIp, DATEN_ORDNER, } from "./workspace.js"; +/* Der Stand der Einblendung kommt aus derselben Quelle wie im Saal. + Er stand hier einmal abgeschrieben -- siehe die Begruendung + dort. */ +import { werbungStand } from "./reaktion-tabellen.js"; export const buehneRouter = express.Router(); @@ -201,6 +205,15 @@ buehneRouter.get("/workspace/api/buehne/lage", mitSchluessel, (req, res) => { sehen sein soll: Bei „nur Kamera" darf im Stream kein stehengebliebenes YouTube-Bild liegen. */ layout: s?.layout || "kino", + /* ==== DIE WERBUNG GEHOERT IN DEN STREAM ================== + Sie ist der Grund, warum es sie gibt: Ein Band, das nur auf + der Seite laeuft, erreicht die Leute nicht, die ueber + TikTok oder YouTube zusehen -- und das sind die meisten. + + Die Botschaften kommen MIT, nicht nur ihre Kennungen. Die + Buehnenquelle hat keine Anmeldung und koennte sie sonst + nirgends nachschlagen. */ + werbung: werbungStand(db()), gesendet: Date.now(), titel: s?.titel || "", }); diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index e7fd5db4..d5998515 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -51,6 +51,15 @@ import { KAMERA_GROESSEN, KAMERA_ECKEN, MELDUNG_TAGE, VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN, + BANNER_ARTEN, + BANNER_TEMPI, + BANNER_MAX, + BANNER_TEXT_MAX, + BANNER_UNTER_MAX, + BANNER_BILDER, + ECKEN_PLAETZE, + ECKEN_GROESSEN, + werbungStand, } from "./reaktion-tabellen.js"; /* DIE SPERREN DES TREFFS GELTEN AUCH HIER. Ein zweites System daneben hiesse, dass jemand im Treff gesperrt ist und in der @@ -298,6 +307,27 @@ function lage(person) { })), chat_modus: s.chat_modus || "offen", chat_modi: CHAT_MODI, + /* ==== DIE WERBUNG SIEHT JEDER ============================= + Sie ist das Einzige auf dieser Seite, das ausdruecklich fuer + alle gedacht ist -- eine Einblendung, die nur der Host sieht, + waere ein Zettel am eigenen Bildschirm. + + ZWEI LISTEN, UND DAS MIT GRUND: `band` sind die Botschaften, + die gerade laufen -- die bekommt jeder. `banner_alle` ist die + Verwaltung samt abgeschalteter Eintraege; die geht nur an den, + der sie bearbeiten darf. Wer einen Entwurf abschaltet, will + ihn nicht trotzdem im Stream haben. */ + werbung: werbungStand(db()), + banner_alle: HOST_ROLLEN.has(person.rolle) + ? db().prepare(`SELECT id, text, unterzeile, bild, aktiv, reihung + FROM reaktion_banner ORDER BY reihung, id`).all() + .map((z) => ({ ...z, aktiv: !!z.aktiv })) + : undefined, + banner_arten: BANNER_ARTEN, + banner_tempi: BANNER_TEMPI, + banner_bilder: BANNER_BILDER, + ecken_plaetze: ECKEN_PLAETZE, + ecken_groessen: ECKEN_GROESSEN, /* WAS DIE MODERATION SEHEN MUSS -- und niemand sonst. Eine Liste von Massnahmen gegen andere ist fuer einen Zuschauer eine Liste darueber, wer sich danebenbenommen hat. */ @@ -1404,6 +1434,206 @@ reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ li } }); +/* ---------- Die Werbeeinblendung ---------------------------------------- */ + +/* ==== EIN WEG FUER DAS GANZE AUSSEHEN ============================== + + Art, Tempo und die beiden Ecken beantworten zusammen eine Frage: + „Was steht gerade ueber dem Bild?" Vier Wege dafuer waeren vier + Rundrufe -- und zwischen ihnen saehen die Zusehenden eine + Mischung aus neuem Band und alter Ecke. + + Dasselbe Muster wie beim Bild (Anordnung, Groesse, Ecke). */ +reaktionRouter.post("/workspace/api/reaktion/werbung", nurHost, + express.json({ limit: "2kb" }), (req, res) => { + try { + const aendern = []; + const werte = []; + + if (req.body?.art !== undefined) { + const a = String(req.body.art); + if (!BANNER_ARTEN.includes(a)) return res.status(400).json({ fehler: "bannerart_unklar" }); + aendern.push("banner_art = ?"); werte.push(a); + } + if (req.body?.tempo !== undefined) { + const t = Number(req.body.tempo); + if (!BANNER_TEMPI.includes(t)) return res.status(400).json({ fehler: "bannertempo_unklar" }); + aendern.push("banner_tempo = ?"); werte.push(t); + } + for (const [feld, spalte] of [["ecke_dogi", "ecke_dogi"], ["ecke_hase", "ecke_hase"]]) { + if (req.body?.[feld] === undefined) continue; + const e = String(req.body[feld]); + if (!ECKEN_PLAETZE.includes(e)) return res.status(400).json({ fehler: "eckenplatz_unklar" }); + aendern.push(`${spalte} = ?`); werte.push(e); + } + if (req.body?.ecke_gross !== undefined) { + const g = Number(req.body.ecke_gross); + if (!ECKEN_GROESSEN.includes(g)) return res.status(400).json({ fehler: "eckengroesse_unklar" }); + aendern.push("ecke_gross = ?"); werte.push(g); + } + if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" }); + + /* ZWEI BILDER AUF DEMSELBEN PLATZ waeren ein Bild auf dem + anderen. Gemeldet wird es, statt es stillschweigend zu + verschieben: Wer es nicht wollte, korrigiert; wer es + wollte, findet seine Absicht nicht heimlich geaendert. */ + const probe = { ...sendung() }; + for (let i = 0; i < aendern.length; i++) { + const feld = aendern[i].split(" ")[0]; + probe[feld] = werte[i]; + } + if (probe.ecke_dogi !== "aus" && probe.ecke_dogi === probe.ecke_hase) { + return res.status(400).json({ fehler: "ecke_belegt" }); + } + + db().prepare(`UPDATE reaktion SET ${aendern.join(", ")} WHERE id = 1`).run(...werte); + melden("reaktion", { was: "werbung", ...werbungJetzt() }); + res.json({ ok: true, ...werbungJetzt() }); + } catch (fehler) { + console.error("[reaktion] Werbung:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* Der Stand der Einblendung -- an EINER Stelle gerechnet. Drei + Aufrufer (setzen, anlegen, aendern) brauchen dasselbe Ergebnis; + dreimal abgeschrieben waere die Gelegenheit, dass einer davon + das Band vergisst. */ +function werbungJetzt() { + return werbungStand(db()); +} + +/* Alle Botschaften samt den abgeschalteten -- nur fuer die Regie. */ +function bannerAlle() { + return db().prepare(`SELECT id, text, unterzeile, bild, aktiv, reihung + FROM reaktion_banner ORDER BY reihung, id`).all() + .map((z) => ({ ...z, aktiv: !!z.aktiv })); +} + +/* Text pruefen -- einmal fuer Anlegen und Aendern. + + LEERZEICHEN WERDEN GESTUTZT, Zeilenumbrueche fallen weg: Ein + Band ist eine Zeile. Ein hineinkopierter Absatz wuerde es + auseinanderreissen, und man saehe es erst im Stream. */ +function bandText(roh, max) { + return String(roh ?? "").replace(/\s+/g, " ").trim().slice(0, max); +} + +reaktionRouter.post("/workspace/api/reaktion/banner", nurHost, + express.json({ limit: "4kb" }), (req, res) => { + try { + const text = bandText(req.body?.text, BANNER_TEXT_MAX); + if (!text) return res.status(400).json({ fehler: "text_fehlt" }); + const unter = bandText(req.body?.unterzeile, BANNER_UNTER_MAX); + const bild = BANNER_BILDER.includes(String(req.body?.bild)) + ? String(req.body.bild) : "keins"; + + const wieviele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_banner").get().n; + if (wieviele >= BANNER_MAX) return res.status(400).json({ fehler: "banner_voll" }); + + const naechste = db().prepare( + "SELECT COALESCE(MAX(reihung), -1) + 1 AS r FROM reaktion_banner").get().r; + db().prepare(`INSERT INTO reaktion_banner + (text, unterzeile, bild, aktiv, reihung, erstellt) VALUES (?,?,?,1,?,?)`) + .run(text, unter, bild, naechste, new Date().toISOString()); + + melden("reaktion", { was: "werbung", ...werbungJetzt() }); + res.json({ ok: true, banner: bannerAlle() }); + } catch (fehler) { + console.error("[reaktion] Banner anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.post("/workspace/api/reaktion/banner/:id(\\d+)", nurHost, + express.json({ limit: "4kb" }), (req, res) => { + try { + const id = Number(req.params.id); + const da = db().prepare("SELECT id FROM reaktion_banner WHERE id = ?").get(id); + if (!da) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const aendern = []; + const werte = []; + if (req.body?.text !== undefined) { + const t = bandText(req.body.text, BANNER_TEXT_MAX); + if (!t) return res.status(400).json({ fehler: "text_fehlt" }); + aendern.push("text = ?"); werte.push(t); + } + if (req.body?.unterzeile !== undefined) { + aendern.push("unterzeile = ?"); + werte.push(bandText(req.body.unterzeile, BANNER_UNTER_MAX)); + } + if (req.body?.bild !== undefined) { + const b = String(req.body.bild); + if (!BANNER_BILDER.includes(b)) return res.status(400).json({ fehler: "bild_unklar" }); + aendern.push("bild = ?"); werte.push(b); + } + if (req.body?.aktiv !== undefined) { + aendern.push("aktiv = ?"); werte.push(req.body.aktiv ? 1 : 0); + } + if (!aendern.length) return res.status(400).json({ fehler: "nichts_zu_tun" }); + + db().prepare(`UPDATE reaktion_banner SET ${aendern.join(", ")} WHERE id = ?`) + .run(...werte, id); + melden("reaktion", { was: "werbung", ...werbungJetzt() }); + res.json({ ok: true, banner: bannerAlle() }); + } catch (fehler) { + console.error("[reaktion] Banner aendern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +reaktionRouter.delete("/workspace/api/reaktion/banner/:id(\\d+)", nurHost, (req, res) => { + try { + const weg = db().prepare("DELETE FROM reaktion_banner WHERE id = ?") + .run(Number(req.params.id)); + if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" }); + melden("reaktion", { was: "werbung", ...werbungJetzt() }); + res.json({ ok: true, banner: bannerAlle() }); + } catch (fehler) { + console.error("[reaktion] Banner loeschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Die Reihenfolge. Getauscht wird mit dem Nachbarn -- so wie in der + Warteschlange der Videos, damit man nicht zwei Bedienungen fuer + dasselbe lernen muss. */ +reaktionRouter.post("/workspace/api/reaktion/banner/:id(\\d+)/schieben", nurHost, + express.json({ limit: "1kb" }), (req, res) => { + try { + const id = Number(req.params.id); + const wohin = String(req.body?.wohin || ""); + if (!["hoch", "runter"].includes(wohin)) { + return res.status(400).json({ fehler: "richtung_unklar" }); + } + const alle = db().prepare( + "SELECT id, reihung FROM reaktion_banner ORDER BY reihung, id").all(); + const i = alle.findIndex((z) => z.id === id); + if (i < 0) return res.status(404).json({ fehler: "nicht_gefunden" }); + const j = wohin === "hoch" ? i - 1 : i + 1; + if (j < 0 || j >= alle.length) return res.status(400).json({ fehler: "nichts_zu_tun" }); + + /* IN EINEM ZUG. Zwei einzelne Schreibvorgaenge liessen + dazwischen zwei Botschaften auf derselben Zahl stehen -- + und die Reihenfolge waere dann Zufall. */ + const setz = db().prepare("UPDATE reaktion_banner SET reihung = ? WHERE id = ?"); + db().exec("BEGIN IMMEDIATE"); + try { + alle.forEach((z, k) => setz.run(k, z.id)); + setz.run(j, alle[i].id); + setz.run(i, alle[j].id); + db().exec("COMMIT"); + } catch (f) { db().exec("ROLLBACK"); throw f; } + + melden("reaktion", { was: "werbung", ...werbungJetzt() }); + res.json({ ok: true, banner: bannerAlle() }); + } catch (fehler) { + console.error("[reaktion] Banner schieben:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /* ---------- Der Live-Chat ----------------------------------------------- */ reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => { @@ -1561,7 +1791,13 @@ function oeffentlich(l) { 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; + /* `banner_alle` KAM AM 29.09. DAZU. Es ist die Verwaltungsliste + der Werbung -- mit den ABGESCHALTETEN Eintraegen darin. Die + laufenden Botschaften stehen ohnehin unter `werbung.band` und + gehoeren allen; ein Entwurf, den jemand bewusst ausgeschaltet + hat, gehoert niemandem. */ + const { zuschauer, ich, liste, meldungen, massnahmen, zweit, + banner_alle: _verwaltung, ...rest } = l; return rest; } diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 6a604ee4..7eb492d3 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`. --> - - + + - + + + -
+ - + + diff --git a/workspace/calls.html b/workspace/calls.html index b2e4609b..937b26f0 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,15 +89,15 @@ - - + + - - - - - - - + + + + + + +