Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /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>
217 lines
7.3 KiB
TypeScript
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,
|
|
};
|
|
}
|