// Der Ereignisbus zwischen Daemon und Browser-Sources. // // Warum Server-Sent Events und nicht WebSocket, wie in chat-control.md // skizziert: Node bringt einen WebSocket-*Client* mit, aber keinen Server - // den muesste man selbst schreiben, inklusive Handshake und Rahmenformat. // SSE ist einfacher HTTP, `EventSource` verbindet sich von allein neu, und // die Richtung stimmt: das Overlay hoert nur zu, es sendet nie. // // Der haeufigste Fehler bei solchen Overlays ist die Browser-Source, die nach // einem Serverneustart tot im Bild haengt. Dagegen stehen hier drei Dinge: // der eingebaute Reconnect von EventSource, ein Herzschlag alle paar // Sekunden, und der Zustands-Schnappschuss, den jeder neue Client als erstes // bekommt - damit ist das Overlay nach dem Neuverbinden sofort wieder // vollstaendig und nicht erst nach der naechsten Chatnachricht. export type ChatEvent = { kind: 'chat'; /** msg-id aus IRC - dieselbe ID, mit der CLEARMSG spaeter loeschen kann. */ id: string; userId: string; login: string; name: string; text: string; color: string | null; emotes: Array<{ id: string; name: string; count: number }>; /** GIFs aus Twitchs Auswahlfenster, mit dem Zeichenbereich im Text. */ gifs: Array<{ id: string; url: string; from: number; to: number }>; first: boolean; mod: boolean; sub: boolean; broadcaster: boolean; bits: number; }; export type AlertKind = | 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow' /** * Kanalpunkt-Einloesung. Bewusst eine eigene Art und nicht die des Abos: * eine eingeloeste Belohnung als "Neues Abo" einzublenden ist eine * Falschmeldung im Bild, und zwar eine, die in der Aufzeichnung bleibt. */ | 'reward'; export type AlertEvent = { kind: 'alert'; type: AlertKind; id: string; name: string; /** Bits, Zuschauer beim Raid, Anzahl verschenkter Abos. */ amount?: number; months?: number; tier?: string; recipient?: string; message?: string; /** Titel der eingeloesten Belohnung. Nur bei `reward`. */ reward?: string; }; export type StreamEvent = { kind: 'stream'; live: boolean; title: string; category: string; viewers: number; startedAt: string | null; }; /** * Neuer Stand der Liegestuetze: offen und in diesem Stream gemacht. `dazu` * und `name` nur bei einer Einloesung - eine Korrektur von Hand soll nicht * aussehen wie eine. */ export type LiegestuetzeEvent = { kind: 'liegestuetze'; offen: number; gemacht: number; dazu?: number; name?: string; }; /** * Was der Stream-PC ueber OBS meldet. Kommt ueber die Fernsteuerung * (fern.ts) herein und steht im Schnappschuss, damit das Pult nach dem * Neuverbinden sofort die Szenen kennt. */ export type ObsStandEvent = { kind: 'obs'; /** Ist der Stream-PC ueberhaupt mit dem Hub verbunden? */ pc: boolean; /** Und hat er OBS erreicht? */ obs: boolean; szenen: string[]; aktuell: string | null; /** Toneingaenge, die sich stummschalten lassen. */ ton: Array<{ name: string; stumm: boolean }>; /** Laeuft der Wiedergabepuffer? Ohne ihn gibt es keinen Clip. */ puffer: boolean; }; export type OverlayEvent = | ChatEvent | AlertEvent | StreamEvent | { kind: 'hype'; score: number; rate: number } /** * Konfetti als eigenes Ereignis, nicht als Nebenwirkung einer Einblendung. * So muss die Effektseite nicht wissen, welche Belohnung welche Aktion * ausloest - das entscheidet der Hub. */ | { kind: 'konfetti'; staerke: number } /** * Vorzulesender Text. Geht nur raus, wenn er freigegeben wurde oder die * Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub * (apps/hub/src/rewards.ts), nicht hier. */ | { kind: 'tts'; id: string; name: string; text: string; /** Liegt unter /tts/audio/ bereit. Ohne Ton wird nur angezeigt. */ audio?: boolean; } /** Alles sofort still. Fuer den Panikschalter. */ | { kind: 'still' } | LiegestuetzeEvent | { kind: 'scene'; scene: string } /** * Ein Sound vom Pult. Wie beim Vorlesen geht nur die ID hinaus; die Datei * holt sich die Seite /sounds einzeln, damit Chat und Statusleiste keine * Megabytes mitschleppen. */ | { kind: 'sound'; id: string; name: string; laut: number } /** Alle laufenden Sounds abbrechen. Vorlesen bleibt davon unberuehrt. */ | { kind: 'sound-stopp' } /** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */ | { kind: 'sounds-geaendert' } | ObsStandEvent | { kind: 'clear'; userId?: string; messageId?: string } | { kind: 'beat'; t: number } | { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[]; /** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */ liegestuetze: { offen: number; gemacht: number }; obs: ObsStandEvent; }; /** * So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr * braucht keine Seite: der Chat zeigt ein gutes Dutzend, und ein Alarm von * vor zehn Minuten soll nach einem Neustart gerade nicht noch einmal * durchlaufen. */ const KEEP_CHAT = 30; const KEEP_ALERTS = 5; export type Bus = { publish: (ev: OverlayEvent) => void; /** Meldet einen Zuhoerer an. Der Rueckgabewert meldet ihn wieder ab. */ subscribe: (fn: (ev: OverlayEvent) => void) => () => void; /** Der Zustand, den ein neuer Client als erstes bekommt. */ snapshot: () => OverlayEvent; clients: () => number; }; export function createBus(): Bus { const listeners = new Set<(ev: OverlayEvent) => void>(); const chat: ChatEvent[] = []; const alerts: AlertEvent[] = []; let stream: StreamEvent = { kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null, }; let liegestuetze = { offen: 0, gemacht: 0 }; let obs: ObsStandEvent = { kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false, }; return { publish(ev) { // Mitschreiben, bevor verteilt wird: ein Client, der genau jetzt // verbindet, soll dasselbe sehen wie einer, der schon verbunden war. if (ev.kind === 'chat') { chat.push(ev); if (chat.length > KEEP_CHAT) chat.shift(); } else if (ev.kind === 'alert') { alerts.push(ev); if (alerts.length > KEEP_ALERTS) alerts.shift(); } else if (ev.kind === 'stream') { stream = ev; } else if (ev.kind === 'liegestuetze') { liegestuetze = { offen: ev.offen, gemacht: ev.gemacht }; } else if (ev.kind === 'obs') { obs = ev; } else if (ev.kind === 'clear') { // Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der // naechste Neustart der Browser-Source es zurueck ins Bild. for (let i = chat.length - 1; i >= 0; i--) { const m = chat[i]!; if ((ev.messageId && m.id === ev.messageId) || (ev.userId && m.userId === ev.userId)) chat.splice(i, 1); } if (!ev.messageId && !ev.userId) chat.length = 0; } for (const fn of listeners) { // Ein kaputter Zuhoerer darf die anderen nicht mitreissen. try { fn(ev); } catch { /* der Transport meldet es selbst */ } } }, subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze, obs }), clients: () => listeners.size, }; }