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>
156 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|