Files
abdulive/apps/overlay-server/src/bus.ts
T
ZeasyAlexandClaude Opus 5 bc2c8f6cbe Overlay: fuenf Browser-Sources fuer OBS
Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein
Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein
Ding, das mitten im Stream kaputtgehen kann.

Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md:
Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource`
verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert
nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge:
der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein
SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar),
und ein Zustands-Schnappschuss beim Verbinden.

Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT
herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur
ueber EventSub.

GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten.

  gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif
  PRIVMSG #abdulive_ :[Idk GIF by Respective]

Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn.
Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]"
im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094
Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse
eben nicht im Text steht.

Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind:

- Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der
  Ausblend-Animation aus dem DOM verschwinden, und lief in eine
  Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag
  eingefroren.
- Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim
  Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe
  gueltig aus und wuerde den falschen Textteil ersetzen.

Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und
jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur
pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist
waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht,
sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus.

build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket
404 statt Overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:28 +02:00

156 lines
5.3 KiB
TypeScript

// 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;
};
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 }
/** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' }
| { kind: 'scene'; scene: string }
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
/**
* 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,
};
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 === '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] }),
clients: () => listeners.size,
};
}