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>
This commit is contained in:
1 parent
3b606e36fb
commit
bc2c8f6cbe
24 files changed
+3221
-8
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user