Filipe: „die videos muessen perfekt laufen wenn ich die reaktions
mache."
GEMESSEN, NICHT VERMUTET. mess-reaktion hat einen neuen Abschnitt:
Er liest den Spielstand am ECHTEN <video>-Element in beiden Browsern,
rechnet beide auf denselben Augenblick um und sagt, wie weit Host und
Zuschauerin auseinanderliegen. Die vorhandene Pruefung fragte nur, ob
Start und Stopp ANKOMMEN -- zwei Videos koennen beide laufen und
trotzdem zwanzig Sekunden auseinander sein.
vorher nachher
Im Lauf 0,12 s -0,04 s
Wer dazukommt 6,91 s 0,26 s
Uhr des Geraets 30 s vor -29,97 s 0,26 s
DER SCHLIMMSTE FUND: DIE UHR DES TELEFONS
Der Zuschauer rechnete `Date.now() - stand.gesendet`. `gesendet` kam
vom SERVER, `Date.now()` vom Geraet -- zwei Uhren, die nie jemand
verglichen hat. Wessen Telefon dreissig Sekunden vorgeht, schaute
29,97 s weiter als alle anderen. Kein langsames Netz, kein schwaches
Handy. Oertlich faellt das nie auf: Auf einem Rechner sind beide
Uhren dieselbe.
Jetzt geht ein ALTER hinaus statt einer Uhrzeit -- die Differenz
zweier SERVERzeiten. Der Empfaenger braucht dafuer gar keine Uhr,
nur eine Stoppuhr (`performance.now()`), und die haelt Zeitumstellung
und Aufwachen aus dem Ruhezustand aus.
WEITER
* WER DAZUKOMMT, STEIGT RICHTIG EIN. Der gespeicherte Stand war so
alt wie der letzte Takt des Hosts; neue Spalte `sekunde_am` sagt,
wann er galt.
* DAS TEMPO GEHT IN DIE HOCHRECHNUNG EIN. Bei 2x laeuft die Videouhr
doppelt so schnell -- das fehlte ganz.
* JEDER RECHNET ALLE ZWEI SEKUNDEN SELBST NACH statt nur auf Zuruf.
Wer stockte, hing bis zu fuenf Sekunden hinterher.
* DER VORLAUF NACH EINEM SPRUNG WIRD GEREGELT, nicht geschaetzt --
gemessen wird der Rest, nicht die Ladezeit (siehe unten).
* PUFFERN IST AUCH IN DER TRANSPORTLEISTE KEINE PAUSE. Der
Start-Stopp-Knopf rief beim Puffern `playVideo()` -- wer Pause
drueckte, bekam „weiter". Lampe und Knopfbild flackerten bei jedem
Stocken. Die Lektion vom 29.09. war nur an einer Stelle eingeloest.
* DAS TEMPO MELDET UEBER `hostMelden()` statt ein zweites Mal von
Hand -- es hielt Puffern fuer Stillstand und hielt damit beim
Umschalten alle an.
* EIN VIDEO-RUNDRUF STATT DREI ABSCHRIFTEN. Beim Einbau hatte ich
zuerst nur eine nachgezogen; das Tauschen der beiden Videos haette
still die alte Rechnung weiterverschickt.
DREI EIGENE FEHLER UNTERWEGS, alle im Text festgehalten
1. ZWEI FUNKTIONEN HIESSEN `springen`. Meine „spring AUF die Stelle"
wurde von der vorhandenen „spring UM so viele Sekunden"
ueberschrieben -- lautlos, rueckwirkend fuer die ganze Datei. Ein
Zuschauer bei Sekunde 100, dem „geh auf 101" gesagt wird, waere
bei 201 gelandet. DREI BROWSERLAEUFE HABEN ES NICHT GEFUNDEN:
oertlich blieb der Abstand unter der Sprungschwelle, die Zeile
lief kein einziges Mal. Gefunden hat es erst eine Frage an die
ganze Datei -- die steht jetzt als Pruefung drin und wurde
nachgestellt (rot).
2. DER VORLAUF LERNTE NUR NACH OBEN. Gemessen wurde die Ladezeit
nach einem Sprung; ein Sprung in geladenes Material dauert aber
gar nicht, meldet nichts und lehrt nichts. Ergebnis: -0,95 s
stabil. Jetzt wird das ERGEBNIS geregelt statt der Ursache.
3. DER AUSREISSER VON 39,84 s WAR MEINE EIGENE MESSUNG -- der Block
davor schickt eine erfundene Position. Jetzt wird abgeklungen und
in Messreihenfolge ausgegeben.
GEPRUEFT
pruef-reaktion 421/0 (14 neue, darunter der Riegel gegen die
Rueckkehr der Uhrenrechnung und die Wache gegen doppelte
Funktionsnamen -- beide nachgestellt), mess-reaktion ohne ein
einziges ACHTUNG, pruef-buehne 38/0, pruef-struktur, pruef-
zwischenspeicher 34/0.
Schemaaenderung: ADD COLUMN sekunde_am. Datenbank vorher gesichert
und geprueft (integrity_check ok, 20 Personen).
Co-Authored-By: Claude Opus 5 <[email protected]>
597 lines
27 KiB
JavaScript
597 lines
27 KiB
JavaScript
/* =====================================================================
|
|
DIE REACTION — die Tabellen (28.09.2026)
|
|
|
|
Filipe hat eine Kurznotiz geschickt: eine Reaction-Kachel, die
|
|
fuer alle sichtbar ist, normalerweise geschlossen; bei
|
|
Vorbereitung ein Vorschaubild und ein Countdown; ein paar Minuten
|
|
vorher geht der Wartebereich auf, damit die Leute schon
|
|
dasitzen und schreiben koennen; beim Start laufen YouTube-Video
|
|
und Host-Kamera gleichzeitig; ein bis zwei Gaeste mit Kamera und
|
|
Mikrofon; getrennte Lautstaerken; ein Live-Chat mit Moderation;
|
|
und ein Weg, den Host zu unterstuetzen.
|
|
|
|
---------------------------------------------------------------------
|
|
EINE SENDUNG, NICHT VIELE
|
|
|
|
Es gibt genau EINE Reaction-Zeile. Sie wird nicht angelegt und
|
|
geloescht, sie wechselt ihren Stand: zu -> vorbereitung -> live ->
|
|
zu. Der Grund ist nicht Bequemlichkeit, sondern Wahrheit: Eine
|
|
Kachel, die „offen" oder „geschlossen" anzeigt, muss auf EINE
|
|
Zeile zeigen koennen. Bei einer Liste von Sendungen waere die
|
|
erste Frage jedes Mal „welche denn?", und die Antwort darauf haette
|
|
jede der sieben Stellen einzeln gegeben, die den Stand anzeigen.
|
|
|
|
Was vorbei ist, wandert nach `reaktion_verlauf`. Damit bleibt die
|
|
Geschichte erhalten, ohne dass die Gegenwart mehrdeutig wird.
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM DAS VIDEO NICHT UEBER DEN SERVER LAEUFT
|
|
|
|
Naheliegend waere: der Host spielt das YouTube-Video ab, und alle
|
|
sehen seinen Bildschirm. Das waere aus zwei Gruenden falsch.
|
|
|
|
Erstens rechtlich: Ein YouTube-Video an Dritte weiterzusenden ist
|
|
eine oeffentliche Wiedergabe und damit genau die Sache, fuer die
|
|
Kanaele gesperrt werden. Jeder Zuschauer laedt das Video deshalb
|
|
SELBST von YouTube -- so wie bei jeder Watch-Party.
|
|
|
|
Zweitens technisch: Ein weitergesendetes Video kostet Bandbreite
|
|
und Qualitaet. Uebertragen wird stattdessen nur, WO das Video
|
|
gerade steht (Kennung, laeuft/pausiert, Sekunde). Das sind ein paar
|
|
Byte, und jeder sieht es in voller Qualitaet.
|
|
|
|
---------------------------------------------------------------------
|
|
DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH
|
|
|
|
Der Anrufteil des Hauses (workspace-anruf.js, assets/js/anruf.js)
|
|
macht das seit dem 18.09. und hat STUN/TURN eingerichtet. Die
|
|
Reaction benutzt denselben Weg, nur mit mehr Empfaengern: Der Host
|
|
baut zu jedem Zuschauer eine eigene Verbindung auf.
|
|
|
|
DAS HAT EINE GRENZE, UND SIE IST GEMESSEN, NICHT GERATEN. Jede
|
|
Verbindung kostet den Host Upload. Bei 360p und rund 350 kbit/s
|
|
sind zwoelf Zuschauer etwa 4 Mbit/s -- das traegt eine normale
|
|
Leitung. Darueber schaltet die Sendung von selbst auf Ton um: Wer
|
|
keine Kamera mehr bekommt, hoert alles, sieht das Video und kann
|
|
schreiben. Das ist ehrlicher als eine Verbindung, die stockt, und
|
|
es ist sichtbar: Die Zahl steht im Regiepult.
|
|
|
|
Heute sind es elf Menschen im ganzen Haus (gemessen am 28.09.2026:
|
|
1 admin, 1 hand, 1 linke, 4 modi, 4 gast). Die Grenze ist also
|
|
weit weg -- sie steht trotzdem hier, weil sie sonst erst auffaellt,
|
|
wenn es zu spaet ist.
|
|
===================================================================== */
|
|
|
|
/** Wie lange ein beendeter Verlaufseintrag aufgehoben wird. */
|
|
export const VERLAUF_TAGE = 180;
|
|
|
|
/** Hoechstens so viele Zuschauer bekommen Kamerabild. Darueber: nur Ton. */
|
|
export const SICHT_PLAETZE = Number(process.env.REAKTION_SICHT_PLAETZE || 12);
|
|
|
|
/** Hoechstens so viele Gaeste gleichzeitig (Filipes Notiz: 1-2). */
|
|
export const GAESTE_MAX = 2;
|
|
|
|
/** Ein Chatbeitrag in der Reaction. */
|
|
export const TEXT_MAX = 400;
|
|
|
|
/** So viele Beitraege je Minute und Person. */
|
|
export const SCHREIB_BREMSE = 20;
|
|
|
|
/** Die Staende, die es gibt -- und nur diese. */
|
|
export const STAENDE = ["zu", "vorbereitung", "live"];
|
|
|
|
/** 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"];
|
|
|
|
/** Wie gross die Kamerafenster sind (Faktor auf die Grundgroesse).
|
|
*
|
|
* Filipe: „die verschiedenen groessen von kamera."
|
|
*
|
|
* SECHS STUFEN UND KEIN SCHIEBER. Ein Schieber verspricht eine
|
|
* stufenlose Einstellung; im Live sucht dann jemand mitten in der
|
|
* Sendung nach „dem richtigen Wert" statt zu senden. Sechs Stufen
|
|
* trifft man blind.
|
|
*
|
|
* DIE GRENZEN SIND GEMESSEN. Unter 0,6 ist der Name unter dem Bild
|
|
* nicht mehr zu lesen (das Schild steht bei 11,52 px und waechst
|
|
* nicht mit); ueber 2 deckt ein einzelnes Fenster bei „Video gross"
|
|
* mehr als die Haelfte der Leinwand zu, und dann ist es keine
|
|
* Anordnung mehr, sondern die falsche. */
|
|
export const KAMERA_GROESSEN = [0.6, 0.8, 1, 1.25, 1.5, 2];
|
|
|
|
/** In welcher Ecke die Kamerafenster liegen.
|
|
*
|
|
* NUR BEI „VIDEO GROSS" -- in den anderen Anordnungen stehen sie
|
|
* mittig oder fuellen die Flaeche, da gibt es keine Ecke. Genau das
|
|
* sagt die Regie auch, statt einen Knopf anzubieten, der nichts
|
|
* tut.
|
|
*
|
|
* WARUM ES DIESE EINSTELLUNG UEBERHAUPT BRAUCHT: Unten rechts liegt
|
|
* bei TikTok die Knopfreihe, bei YouTube die Fortschrittsleiste,
|
|
* und Untertitel stehen fast immer unten. Eine feste Ecke ist eine,
|
|
* die bei jeder zweiten Plattform falsch ist. */
|
|
export const KAMERA_ECKEN = ["ol", "or", "ul", "ur"];
|
|
|
|
/** 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).
|
|
*
|
|
* Eine Stunde. Wer laenger etwas braucht, nimmt die Massnahmen des
|
|
* Treffs -- die haben eine Begruendungspflicht, eine Frist in Tagen
|
|
* und stehen in der Geschichte. Eine Stummschaltung ueber Stunden
|
|
* waere eine Sperre ohne all das. */
|
|
export const STUMM_MAX_MINUTEN = 60;
|
|
|
|
/** So lange bleibt eine erledigte Meldung stehen.
|
|
*
|
|
* DREISSIG TAGE, UND NICHT LAENGER. Eine Meldung enthaelt drei
|
|
* Dinge, die zusammen empfindlich sind: was jemand geschrieben hat,
|
|
* wer es war und wer ihn gemeldet hat. Solange dieselbe Sendung
|
|
* laeuft, ist das die Arbeitsgrundlage der Moderation; danach ist es
|
|
* eine Liste darueber, wer sich mal danebenbenommen hat -- und wer
|
|
* das gemeldet hat.
|
|
*
|
|
* Meistens sind sie ohnehin frueher weg: Der Live-Chat wird beim
|
|
* Beenden der Sendung geloescht, und die Meldungen haengen mit
|
|
* `ON DELETE CASCADE` daran. Diese Frist greift nur fuer den Fall,
|
|
* dass eine Sendung nie ordentlich beendet wurde. */
|
|
export const MELDUNG_TAGE = 30;
|
|
|
|
/** Die zwei kurzen Massnahmen der Sendung. */
|
|
export const MASSNAHMEN = ["stumm", "raus"];
|
|
|
|
/** Wer im Live-Chat schreiben darf.
|
|
*
|
|
* Filipes Notiz nennt „Chat" als eine der acht Host-Steuerungen.
|
|
* Gemeint ist nicht die Moderation -- die gab es schon -- sondern
|
|
* der Chat selbst: Einen Beitrag wegzunehmen, nachdem er stand, ist
|
|
* etwas anderes, als ihn gar nicht erst zuzulassen.
|
|
*
|
|
* DREI STUFEN UND NICHT ZWEI. „Zu" ist in einer Sendung fast immer
|
|
* zu viel -- dann sitzen alle vor einem stummen Fenster. Die
|
|
* mittlere Stufe ist die, die man wirklich braucht: Es wird laut,
|
|
* das Team ordnet kurz, danach geht es wieder auf. */
|
|
export const CHAT_MODI = ["offen", "team", "zu"];
|
|
|
|
/** Hoechstens so viele Videos duerfen in der Warteschlange stehen.
|
|
*
|
|
* Die Zahl ist keine technische Grenze, sondern eine Ansage: Wer
|
|
* dreissig Videos einraeumt, plant keinen Abend mehr, sondern legt
|
|
* ein Archiv an -- und dafuer ist die Liste nicht gebaut. Sie waere
|
|
* danach zu lang, um sie im Pult zu ueberblicken. */
|
|
/* Ueber die Umgebung veraenderbar -- damit die Pruefung den vollen
|
|
Fall in Sekunden erreicht und nicht dreissig Videos anhaengen muss.
|
|
Ein Grenzfall, den niemand prueft, weil das Pruefen zu lange
|
|
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
|
|
Programm: Ein Programm kann man umgehen, eine Bedingung nicht. */
|
|
CREATE TABLE IF NOT EXISTS reaktion (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
stand TEXT NOT NULL DEFAULT 'zu'
|
|
CHECK (stand IN ('zu','vorbereitung','live')),
|
|
titel TEXT NOT NULL DEFAULT '',
|
|
video TEXT NOT NULL DEFAULT '',
|
|
vorschau TEXT NOT NULL DEFAULT '',
|
|
beginnt_am TEXT,
|
|
host_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
layout TEXT NOT NULL DEFAULT 'kino',
|
|
laeuft INTEGER NOT NULL DEFAULT 0,
|
|
sekunde REAL NOT NULL DEFAULT 0,
|
|
stand_seit TEXT NOT NULL DEFAULT '',
|
|
gestartet_am TEXT,
|
|
geaendert TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
/* Wer gerade mit Kamera dabei ist. Der Host steht NICHT hier --
|
|
er ist an der Sendung selbst vermerkt. Ein Gast, der geht,
|
|
wird geloescht; wer stumm ist, bleibt stehen. */
|
|
CREATE TABLE IF NOT EXISTS reaktion_gaeste (
|
|
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
seit TEXT NOT NULL,
|
|
stumm INTEGER NOT NULL DEFAULT 0,
|
|
platz INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
|
|
/* Wer gerade zusieht. Wird beim Verlassen geloescht und von der
|
|
Pflege entfernt, wenn sich jemand zwei Minuten nicht meldet.
|
|
Daraus kommt die Zuschauerzahl -- und die Entscheidung, wer
|
|
noch ein Kamerabild bekommt. */
|
|
CREATE TABLE IF NOT EXISTS reaktion_dabei (
|
|
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
seit TEXT NOT NULL,
|
|
gesehen TEXT NOT NULL,
|
|
sicht INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
/* Der Live-Chat. Eigene Tabelle und nicht der Hauschat: Was hier
|
|
gesagt wird, gehoert zu dieser einen Sendung und ist danach
|
|
vorbei. Im Hauschat wuerde es die Gespraeche der Leute
|
|
ueberschwemmen, und die Moderation waere eine andere. */
|
|
CREATE TABLE IF NOT EXISTS reaktion_chat (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
rolle TEXT NOT NULL DEFAULT '',
|
|
text TEXT NOT NULL,
|
|
erstellt TEXT NOT NULL,
|
|
weg_am TEXT,
|
|
weg_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
|
|
/* ==== DIE KURZEN MASSNAHMEN EINER SENDUNG ====================
|
|
|
|
„stumm" darf zusehen, aber nicht schreiben. „raus" ist aus
|
|
dieser Sendung heraus.
|
|
|
|
BEIDES ENDET MIT DER SENDUNG -- wie die Gaeste und der
|
|
Live-Chat auch. Was laenger gelten soll, gehoert in die
|
|
Massnahmen des Treffs: Die haben eine Begruendungspflicht,
|
|
eine Frist in Tagen und stehen in der Geschichte. Ein
|
|
Fuenf-Minuten-Aerger gehoert dort nicht hinein.
|
|
|
|
EINE ZEILE JE PERSON. Zwei Massnahmen gegen dieselbe Person
|
|
waeren zwei Antworten auf dieselbe Frage -- und dann
|
|
entscheidet die Reihenfolge, welche gilt. */
|
|
CREATE TABLE IF NOT EXISTS reaktion_massnahmen (
|
|
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
|
art TEXT NOT NULL CHECK (art IN ('stumm','raus')),
|
|
/* Leer heisst: bis zum Ende der Sendung. */
|
|
bis TEXT,
|
|
grund TEXT NOT NULL DEFAULT '',
|
|
von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
erstellt TEXT NOT NULL
|
|
);
|
|
|
|
/* Gemeldete Beitraege. Eigene Tabelle und nicht die des Treffs:
|
|
Die zeigt auf „eintraege“, hier geht es um „reaktion_chat“ --
|
|
ein anderer Gegenstand, und ein Fremdschluessel kann nicht auf
|
|
zweierlei zeigen. */
|
|
CREATE TABLE IF NOT EXISTS reaktion_meldungen (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
beitrag_id INTEGER NOT NULL REFERENCES reaktion_chat(id) ON DELETE CASCADE,
|
|
melder_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
grund TEXT NOT NULL DEFAULT '',
|
|
erstellt TEXT NOT NULL,
|
|
erledigt_am TEXT,
|
|
erledigt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
|
);
|
|
/* DIESELBE PERSON MELDET DENSELBEN BEITRAG EINMAL. Ohne diese
|
|
Sperre koennte einer allein eine Liste fuellen, und die
|
|
Moderation sieht vor lauter Meldungen die echte nicht. */
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_reaktion_meldung_einmal
|
|
ON reaktion_meldungen (beitrag_id, melder_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reaktion_meldung_offen
|
|
ON reaktion_meldungen (erledigt_am, id);
|
|
|
|
/* Was gelaufen ist. Eine Sendung je Zeile, erst beim Beenden. */
|
|
CREATE TABLE IF NOT EXISTS reaktion_verlauf (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titel TEXT NOT NULL DEFAULT '',
|
|
video TEXT NOT NULL DEFAULT '',
|
|
host_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
begonnen TEXT,
|
|
beendet TEXT NOT NULL,
|
|
zuschauer INTEGER NOT NULL DEFAULT 0,
|
|
beitraege INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
/* ==== DIE WARTESCHLANGE ======================================
|
|
|
|
Was als Naechstes laeuft. Eine Zeile je Video, die Spalte „platz“ gibt
|
|
die Reihenfolge.
|
|
|
|
SIE IST NICHT DASSELBE WIE DER VERLAUF. Der Verlauf sagt, was
|
|
gelaufen IST -- die Warteschlange, was laufen SOLL. Beides in
|
|
eine Tabelle zu legen waere sparsam und falsch: Ein Eintrag
|
|
muesste dann „schon gelaufen ja/nein" tragen, und jede Abfrage
|
|
im Haus muesste diese Frage mitstellen. Vergisst sie eine,
|
|
steht ein Video zweimal im Abend.
|
|
|
|
DER TITEL STEHT HIER MIT DRIN und wird nicht jedes Mal neu bei
|
|
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,
|
|
titel TEXT NOT NULL DEFAULT '',
|
|
platz INTEGER NOT NULL DEFAULT 0,
|
|
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
gesetzt_am TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reaktion_liste_platz
|
|
ON reaktion_liste (platz, id);
|
|
CREATE INDEX IF NOT EXISTS idx_reaktion_chat_zeit
|
|
ON reaktion_chat (weg_am, id);
|
|
CREATE INDEX IF NOT EXISTS idx_reaktion_dabei_gesehen
|
|
ON reaktion_dabei (gesehen);
|
|
`);
|
|
|
|
/* DIE EINE ZEILE ANLEGEN, WENN ES SIE NOCH NICHT GIBT.
|
|
|
|
`INSERT OR IGNORE` und nicht „erst fragen, dann schreiben": Zwei
|
|
Anfragen gleichzeitig beim ersten Start wuerden sonst beide
|
|
„gibt es nicht" lesen und beide schreiben wollen. */
|
|
d.prepare(`INSERT OR IGNORE INTO reaktion
|
|
(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
|
|
umkopieren". Am 11.09.2026 hat genau dieses Umkopieren an
|
|
anderer Stelle drei Spalten mit Inhalt verschluckt, ohne
|
|
Fehlermeldung und bei unveraenderter Zeilenzahl -- weil die
|
|
Spaltenliste von Hand abgeschrieben war. Eine Liste, die
|
|
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("sekunde_am")) {
|
|
/* WANN DIE SEKUNDE GALT -- in Millisekunden der Serveruhr.
|
|
(30.09.2026)
|
|
|
|
Ohne sie liess sich nicht sagen, wie alt der gespeicherte
|
|
Spielstand ist. Wer mitten in der Sendung dazukam, bekam
|
|
`sekunde` aus der Datenbank und setzte sich damit dorthin --
|
|
gemessen 6,91 s hinter allen anderen, weil der Host nur alle
|
|
fuenf Sekunden meldet.
|
|
|
|
Sie wird NICHT an den Browser weitergegeben. Hinausgeschickt
|
|
wird nur die Differenz `Date.now() - sekunde_am`, also ein
|
|
ALTER. Eine absolute Serverzeit koennte der Empfaenger nur mit
|
|
seiner eigenen Uhr verrechnen, und genau das war der Fehler:
|
|
Ein Geraet mit 30 s falscher Uhr schaute 30 s woanders. */
|
|
d.exec("ALTER TABLE reaktion ADD COLUMN sekunde_am INTEGER NOT NULL DEFAULT 0");
|
|
console.log("[reaktion] Spalte sekunde_am angelegt.");
|
|
}
|
|
|
|
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")) {
|
|
/* WER GERADE SEINEN BILDSCHIRM TEILT.
|
|
Es ist eine eigene Spalte und nicht „kamera_aus umgedreht":
|
|
Beim Teilen geht ein Bild hinaus, es ist nur ein anderes. Wer
|
|
beides in eine Spalte legte, koennte nachher nicht mehr
|
|
sagen, ob da ein schwarzes Fenster steht oder ein
|
|
Schreibtisch. */
|
|
d.exec("ALTER TABLE reaktion_dabei ADD COLUMN bildschirm INTEGER NOT NULL DEFAULT 0");
|
|
console.log("[reaktion] Spalte bildschirm angelegt.");
|
|
}
|
|
if (!dabei.has("kamera_aus")) {
|
|
/* Wer sein Bild gerade abgeschaltet hat.
|
|
AN DIESER TABELLE UND NICHT AN DEN GAESTEN: Hier steht, wer
|
|
gerade da ist -- Host, Gaeste und Zuschauer in einer Liste.
|
|
Genau die richtige Lebensdauer: Wer geht, nimmt seinen Zustand
|
|
mit, und die Pflege raeumt ihn ohnehin weg. */
|
|
d.exec("ALTER TABLE reaktion_dabei ADD COLUMN kamera_aus INTEGER NOT NULL DEFAULT 0");
|
|
console.log("[reaktion] Spalte kamera_aus angelegt.");
|
|
}
|
|
if (!dabei.has("mikro_aus")) {
|
|
/* Und dasselbe fuers Mikro. Wer sich selbst stummschaltet und
|
|
trotzdem redet, saehe sonst bei allen anderen ein ganz
|
|
normales Fenster -- niemand merkt, dass nichts ankommt, und er
|
|
selbst am wenigsten. */
|
|
d.exec("ALTER TABLE reaktion_dabei ADD COLUMN mikro_aus INTEGER NOT NULL DEFAULT 0");
|
|
console.log("[reaktion] Spalte mikro_aus angelegt.");
|
|
}
|
|
if (!da.has("kamera_gross")) {
|
|
/* Groesse und Ecke der Kamerafenster. Beide gelten fuer die
|
|
ganze Sendung -- wer sendet, stellt das Bild fuer alle ein. */
|
|
d.exec("ALTER TABLE reaktion ADD COLUMN kamera_gross REAL NOT NULL DEFAULT 1");
|
|
d.exec("ALTER TABLE reaktion ADD COLUMN kamera_ecke TEXT NOT NULL DEFAULT 'ur'");
|
|
console.log("[reaktion] Spalten fuer die Kameragroesse 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
|
|
elfstellige Kennung -- die sagt niemandem etwas. */
|
|
d.exec("ALTER TABLE reaktion ADD COLUMN video_titel TEXT NOT NULL DEFAULT ''");
|
|
console.log("[reaktion] Spalte video_titel angelegt.");
|
|
}
|
|
}
|