Files
abdulive/apps/overlay-server/src/bus.ts
T
ZeasyAlexandClaude Opus 5.5 8be195d938
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole)
- Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen
  ueber die neue Browser-Source /sounds
- Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem
  Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS
- Browser-Sources melden, welche Seite sie sind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:48 +01:00

217 lines
7.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;
};
/**
* 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/<id> 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,
};
}