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
@@ -19,7 +19,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
|
||||
clock += advanceMs;
|
||||
return {
|
||||
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
|
||||
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [],
|
||||
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [],
|
||||
isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
|
||||
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# @abdulive/overlay-server
|
||||
|
||||
WebSocket-Backend und Command-Registry fuer alle Overlays.
|
||||
Der einzige Server, der von außen erreichbar ist. Liefert die Seiten aus
|
||||
[`overlay-web`](../overlay-web/README.md) aus, hält den Ereignisstrom dorthin
|
||||
offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
|
||||
|
||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||
```
|
||||
/ Übersicht mit den Adressen für OBS Passwort
|
||||
/setup Einrichtung: Twitch-Apps, Tokens Passwort
|
||||
/callback OAuth-Rückruf von Twitch offen, `state` schützt
|
||||
/alerts … die Browser-Sources Schlüssel in der URL
|
||||
/events der Ereignisstrom dahinter Schlüssel in der URL
|
||||
```
|
||||
|
||||
## Server-Sent Events statt WebSocket
|
||||
|
||||
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
|
||||
WS-Kanal. Es sind SSE geworden, aus drei Gründen:
|
||||
|
||||
1. Node bringt einen WebSocket-*Client* mit, aber keinen Server. Den müsste
|
||||
man selbst schreiben, inklusive Handshake und Rahmenformat - in einem
|
||||
Projekt ohne Abhängigkeiten heißt das: selbst pflegen.
|
||||
2. `EventSource` verbindet sich von allein neu. Die tote Browser-Source nach
|
||||
einem Neustart ist der häufigste Fehler bei solchen Overlays, und hier
|
||||
löst ihn der Browser.
|
||||
3. Die Richtung stimmt. Das Overlay hört nur zu; es sendet nie.
|
||||
|
||||
Drei Dinge halten die Verbindung ehrlich:
|
||||
|
||||
- **Herzschlag alle 15 s**, als echtes Ereignis und nicht als SSE-Kommentar -
|
||||
ein Kommentar hält die Verbindung offen, ist im Browser aber unsichtbar.
|
||||
- **Wachhund im Client**: 45 s ohne Nachricht heißt neu verbinden. OBS hält
|
||||
eine tote Verbindung erstaunlich lange für lebendig.
|
||||
- **Schnappschuss beim Verbinden**: der letzte Stream-Zustand und die letzten
|
||||
Chatnachrichten, damit die Quelle nach dem Neuverbinden nicht leer im Bild
|
||||
steht.
|
||||
|
||||
## Zugang
|
||||
|
||||
Siehe [ADR-0008](../../docs/decisions/ADR-0008-geteilter-betrieb.md).
|
||||
|
||||
- **Passwort** für alles, was ein Mensch bedient. Setzen mit
|
||||
`abdulive web:passwort`, nie über das Netz - ein Dienst, bei dem man das
|
||||
Passwort über die offene Weboberfläche setzen kann, hat kein Passwort.
|
||||
- **Schlüssel in der Adresse** für die Browser-Sources. Eine Browser-Source
|
||||
kann kein Formular ausfüllen. Der Schlüssel darf nur lesen und lässt sich
|
||||
auf der Übersichtsseite neu würfeln.
|
||||
|
||||
Ohne `publicUrl` bindet der Server weiter auf `127.0.0.1` und verhält sich wie
|
||||
vorher.
|
||||
@@ -0,0 +1,68 @@
|
||||
// Das Sitzungs-Cookie ist der einzige Schutz vor der Einrichtungsseite, auf
|
||||
// der Twitch-Zugangsdaten eingetragen werden. Hier wird geprueft, dass es
|
||||
// haelt, was es soll - und dass es einen Neustart übersteht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { sitzungGueltig, sitzungToken } from './auth.ts';
|
||||
|
||||
const KEY = crypto.randomBytes(32);
|
||||
const HASH = 'ein-passwort-hash';
|
||||
const JETZT = Date.parse('2026-09-17T18:00:00Z');
|
||||
const IN_30_TAGEN = JETZT + 30 * 24 * 3_600_000;
|
||||
|
||||
test('ein frisch ausgestelltes Cookie gilt', () => {
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), true);
|
||||
});
|
||||
|
||||
test('ein Neustart meldet niemanden ab', () => {
|
||||
// Der eigentliche Grund für diese Bauform: der Dienst startet bei jedem
|
||||
// Ausrollen neu. Lag die Sitzungsliste im Arbeitsspeicher, war man danach
|
||||
// jedes Mal draußen. Derselbe Schlüssel - dasselbe Ergebnis, ohne dass
|
||||
// irgendwo etwas gemerkt wurde.
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
const nachNeustart = Buffer.from(KEY); // derselbe Wert, neues Objekt
|
||||
assert.equal(sitzungGueltig(t, nachNeustart, HASH, JETZT), true);
|
||||
});
|
||||
|
||||
test('abgelaufen ist abgelaufen', () => {
|
||||
const t = sitzungToken(KEY, HASH, JETZT - 1);
|
||||
assert.equal(sitzungGueltig(t, KEY, HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('das Ablaufdatum laesst sich nicht verlaengern', () => {
|
||||
// Ohne Signatur ueber das Datum koennte jeder das Cookie umschreiben und
|
||||
// waere damit dauerhaft angemeldet.
|
||||
const t = sitzungToken(KEY, HASH, JETZT + 1000);
|
||||
const [, sig] = t.split('.');
|
||||
const gefaelscht = `${Buffer.from(String(IN_30_TAGEN)).toString('base64url')}.${sig}`;
|
||||
assert.equal(sitzungGueltig(gefaelscht, KEY, HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('ein fremder Schluessel oeffnet nichts', () => {
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, crypto.randomBytes(32), HASH, JETZT), false);
|
||||
});
|
||||
|
||||
test('ein neues Passwort macht alle Cookies ungueltig', () => {
|
||||
// Das ist der Ersatz für eine Abmeldeliste: wer sein Passwort wechselt,
|
||||
// wirft damit alle offenen Sitzungen raus.
|
||||
const t = sitzungToken(KEY, HASH, IN_30_TAGEN);
|
||||
assert.equal(sitzungGueltig(t, KEY, 'neuer-hash', JETZT), false);
|
||||
});
|
||||
|
||||
test('Unsinn im Cookie wird abgewiesen, nicht geglaubt', () => {
|
||||
for (const müll of [undefined, '', 'abc', 'abc.def', '.', 'x.y.z',
|
||||
`${Buffer.from('keine-zahl').toString('base64url')}.xx`]) {
|
||||
assert.equal(sitzungGueltig(müll, KEY, HASH, JETZT), false, `"${müll}" haette abgewiesen gehoert`);
|
||||
}
|
||||
});
|
||||
|
||||
test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
|
||||
// Bewusst so: ohne Zufallsanteil gibt es nichts, was der Server sich merken
|
||||
// müsste. Der Preis ist, dass sich eine einzelne Sitzung nicht gezielt
|
||||
// beenden lässt - dafür gibt es den Passwortwechsel.
|
||||
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// Zugangskontrolle. Nur noetig, seit der Dienst zentral laeuft: solange er an
|
||||
// 127.0.0.1 hing, war "wer am Rechner sitzt, darf" die ganze Regel.
|
||||
//
|
||||
// Zwei Wege, weil es zwei Arten von Besuchern gibt:
|
||||
//
|
||||
// Mensch -> Passwort, einmal eingeben, danach ein Sitzungs-Cookie.
|
||||
// Gilt fuer die Uebersicht, die Einrichtung und den Status.
|
||||
// OBS -> Schluessel in der Adresse (?key=...). Eine Browser-Source
|
||||
// kann kein Formular ausfuellen und keinen Login durchhalten;
|
||||
// ein Geheimnis in der URL ist hier der einzige Weg, der ohne
|
||||
// Handgriff mitten im Stream auskommt.
|
||||
//
|
||||
// Der Schluessel steht damit in jedem OBS-Profil und in jedem Screenshot der
|
||||
// Einstellungen. Deshalb kann er nur lesen, nie schreiben, und er laesst sich
|
||||
// auf der Uebersichtsseite neu wuerfeln.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type http from 'node:http';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
|
||||
|
||||
const log = logger('overlay:zugang');
|
||||
|
||||
/** Wie lange eine Anmeldung gilt. Danach einmal neu eintippen. */
|
||||
const SESSION_MS = 30 * 24 * 3_600_000;
|
||||
const COOKIE = 'abdulive_sitzung';
|
||||
|
||||
export type Zugang = {
|
||||
/** Darf diese Anfrage eine geschuetzte Seite sehen? */
|
||||
istAngemeldet: (req: http.IncomingMessage) => boolean;
|
||||
/** Stimmt der Schluessel in der Adresse? Angemeldete duerfen auch ohne. */
|
||||
darfOverlay: (req: http.IncomingMessage, url: URL) => boolean;
|
||||
/** Prueft das Passwort und gibt bei Erfolg den Cookie-Kopf zurueck. */
|
||||
anmelden: (passwort: string) => string | null;
|
||||
abmelden: () => string;
|
||||
overlayKey: () => string;
|
||||
neuerOverlayKey: () => string;
|
||||
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||
hatPasswort: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vergleich in konstanter Zeit. Bei einem einzelnen Passwortfeld ist der
|
||||
* Zeitunterschied kaum messbar, aber das ist kein Grund, ihn zu verschenken.
|
||||
*/
|
||||
function gleich(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function hash(passwort: string, salt: string): string {
|
||||
return crypto.scryptSync(passwort, salt, 32).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Cookie traegt sein eigenes Ablaufdatum und eine Signatur darueber.
|
||||
* Der Server merkt sich keine Sitzungen.
|
||||
*
|
||||
* Der Grund ist eine gelernte Lektion: eine Liste im Arbeitsspeicher ist
|
||||
* hinfaellig, sobald der Dienst neu startet - und er startet bei jedem
|
||||
* Ausrollen neu. Wer sich nach jeder Aenderung neu anmelden muss, waehlt
|
||||
* irgendwann ein kuerzeres Passwort.
|
||||
*
|
||||
* Mit signiert wird auch der Passwort-Hash. Ein neues Passwort macht damit
|
||||
* alle ausgegebenen Cookies ungueltig, ohne dass es eine Abmeldeliste braucht.
|
||||
*/
|
||||
export function sitzungToken(key: Buffer, passwordHash: string, bisMs: number): string {
|
||||
const bis = String(bisMs);
|
||||
return `${Buffer.from(bis).toString('base64url')}.${signatur(key, passwordHash, bis)}`;
|
||||
}
|
||||
|
||||
export function sitzungGueltig(
|
||||
token: string | undefined, key: Buffer, passwordHash: string, jetztMs = Date.now()
|
||||
): boolean {
|
||||
if (!token) return false;
|
||||
const [bisB64, sig] = token.split('.');
|
||||
if (!bisB64 || !sig) return false;
|
||||
const bis = Buffer.from(bisB64, 'base64url').toString('utf8');
|
||||
if (!/^\d+$/.test(bis) || Number(bis) < jetztMs) return false;
|
||||
return gleich(sig, signatur(key, passwordHash, bis));
|
||||
}
|
||||
|
||||
function signatur(key: Buffer, passwordHash: string, bis: string): string {
|
||||
return crypto.createHmac('sha256', key)
|
||||
.update(`${bis}|${passwordHash}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function cookies(req: http.IncomingMessage): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const teil of (req.headers.cookie ?? '').split(';')) {
|
||||
const i = teil.indexOf('=');
|
||||
if (i > 0) out[teil.slice(0, i).trim()] = decodeURIComponent(teil.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createZugang(): Zugang {
|
||||
function store() {
|
||||
const s = readSecrets();
|
||||
return s.web ?? {};
|
||||
}
|
||||
|
||||
// Beim ersten Start einen Overlay-Schluessel wuerfeln. Ohne einen waere
|
||||
// jede Browser-Source-Adresse zu erraten.
|
||||
function key(): string {
|
||||
const vorhanden = store().overlayKey;
|
||||
if (vorhanden) return vorhanden;
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
log.info('Neuer Overlay-Schluessel erzeugt - die Adressen stehen auf der Uebersichtsseite');
|
||||
return neu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
|
||||
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
|
||||
* sind nur die Anmeldungen weg.
|
||||
*/
|
||||
function sitzungsKey(): Buffer {
|
||||
const vorhanden = store().sessionKey;
|
||||
if (vorhanden) return Buffer.from(vorhanden, 'base64');
|
||||
const neu = crypto.randomBytes(32);
|
||||
updateSecrets((s) => { s.web = { ...s.web, sessionKey: neu.toString('base64') }; });
|
||||
return neu;
|
||||
}
|
||||
|
||||
function istAngemeldet(req: http.IncomingMessage): boolean {
|
||||
const hash = store().passwordHash;
|
||||
if (!hash) return true; // noch nichts eingerichtet
|
||||
return sitzungGueltig(cookies(req)[COOKIE], sitzungsKey(), hash);
|
||||
}
|
||||
|
||||
return {
|
||||
hatPasswort: () => Boolean(store().passwordHash),
|
||||
istAngemeldet,
|
||||
|
||||
darfOverlay(req, url) {
|
||||
const gegeben = url.searchParams.get('key');
|
||||
if (gegeben && gleich(gegeben, key())) return true;
|
||||
// Wer angemeldet ist, darf die Seiten auch im Browser ansehen - sonst
|
||||
// muesste man zum Nachschauen jedes Mal den Schluessel anhaengen.
|
||||
return istAngemeldet(req);
|
||||
},
|
||||
|
||||
anmelden(passwort) {
|
||||
const s = store();
|
||||
if (!s.passwordHash || !s.passwordSalt) return null;
|
||||
if (!gleich(hash(passwort, s.passwordSalt), s.passwordHash)) {
|
||||
log.warn('Fehlgeschlagene Anmeldung');
|
||||
return null;
|
||||
}
|
||||
const token = sitzungToken(sitzungsKey(), s.passwordHash, Date.now() + SESSION_MS);
|
||||
// Kein `Secure`: NPM beendet TLS, und der Dienst selbst spricht HTTP.
|
||||
// Das Cookie geht trotzdem nur ueber die HTTPS-Adresse raus, weil es
|
||||
// keine andere gibt, die von aussen erreichbar ist.
|
||||
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MS / 1000}`;
|
||||
},
|
||||
|
||||
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
overlayKey: key,
|
||||
neuerOverlayKey() {
|
||||
const neu = crypto.randomBytes(18).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
|
||||
log.info('Overlay-Schluessel neu gewuerfelt - die alten Adressen sind damit tot');
|
||||
return neu;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Passwort. Wird von `abdulive web:passwort` aufgerufen, nicht ueber
|
||||
* das Netz - ein Dienst, bei dem man das Passwort ueber die offene
|
||||
* Weboberflaeche setzen kann, hat kein Passwort.
|
||||
*/
|
||||
export function setzePasswort(passwort: string): void {
|
||||
if (passwort.length < 8) throw new Error('Mindestens acht Zeichen.');
|
||||
const salt = crypto.randomBytes(16).toString('base64');
|
||||
updateSecrets((s) => {
|
||||
s.web = { ...s.web, passwordSalt: salt, passwordHash: hash(passwort, salt) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Der Bus entscheidet, was eine frisch verbundene Browser-Source sieht.
|
||||
// Danach richtet sich, ob das Overlay nach einem Neustart vollstaendig ist
|
||||
// oder leer im Bild steht - und ob eine geloeschte Nachricht geloescht bleibt.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBus, type ChatEvent, type OverlayEvent } from './bus.ts';
|
||||
|
||||
function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||
return {
|
||||
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
|
||||
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
|
||||
sub: false, broadcaster: false, bits: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function schnappschuss(bus: ReturnType<typeof createBus>) {
|
||||
const s = bus.snapshot();
|
||||
assert.equal(s.kind, 'state');
|
||||
return s as Extract<OverlayEvent, { kind: 'state' }>;
|
||||
}
|
||||
|
||||
test('ein neuer Zuhoerer bekommt den bisherigen Chat', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish(nachricht({ id: 'b' }));
|
||||
|
||||
const s = schnappschuss(bus);
|
||||
assert.deepEqual(s.chat.map((m) => m.id), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('der Rueckblick waechst nicht unbegrenzt', () => {
|
||||
const bus = createBus();
|
||||
for (let i = 0; i < 100; i++) bus.publish(nachricht({ id: `m${i}` }));
|
||||
|
||||
const s = schnappschuss(bus);
|
||||
assert.equal(s.chat.length, 30);
|
||||
assert.equal(s.chat[s.chat.length - 1]?.id, 'm99', 'die neuesten bleiben');
|
||||
});
|
||||
|
||||
test('der zuletzt gemeldete Stream-Zustand steht im Schnappschuss', () => {
|
||||
const bus = createBus();
|
||||
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||
viewers: 12, startedAt: '2026-09-17T18:00:00Z' });
|
||||
bus.publish({ kind: 'stream', live: true, title: 'Titel', category: 'Elden Ring',
|
||||
viewers: 19, startedAt: '2026-09-17T18:00:00Z' });
|
||||
|
||||
assert.equal(schnappschuss(bus).stream.viewers, 19);
|
||||
});
|
||||
|
||||
test('eine geloeschte Nachricht verschwindet auch aus dem Rueckblick', () => {
|
||||
// Sonst holt der naechste Neustart der Browser-Source sie zurueck ins Bild -
|
||||
// und zwar genau die eine, die ein Mod entfernt hat.
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish(nachricht({ id: 'b' }));
|
||||
bus.publish({ kind: 'clear', messageId: 'a' });
|
||||
|
||||
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
test('ein gesperrter Chatter verschwindet mit allen Nachrichten', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a', userId: 'u1' }));
|
||||
bus.publish(nachricht({ id: 'b', userId: 'u2' }));
|
||||
bus.publish(nachricht({ id: 'c', userId: 'u1' }));
|
||||
bus.publish({ kind: 'clear', userId: 'u1' });
|
||||
|
||||
assert.deepEqual(schnappschuss(bus).chat.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
test('ein geleerter Chat leert auch den Rueckblick', () => {
|
||||
const bus = createBus();
|
||||
bus.publish(nachricht({ id: 'a' }));
|
||||
bus.publish({ kind: 'clear' });
|
||||
assert.deepEqual(schnappschuss(bus).chat, []);
|
||||
});
|
||||
|
||||
test('alte Einblendungen werden nicht endlos aufbewahrt', () => {
|
||||
const bus = createBus();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
bus.publish({ kind: 'alert', type: 'sub', id: `a${i}`, name: 'x' });
|
||||
}
|
||||
assert.equal(schnappschuss(bus).alerts.length, 5);
|
||||
});
|
||||
|
||||
test('ein Zuhoerer, der wirft, reisst die anderen nicht mit', () => {
|
||||
// Eine Browser-Source, deren Verbindung gerade zusammengebrochen ist, darf
|
||||
// den Chat nicht fuer alle anderen anhalten.
|
||||
const bus = createBus();
|
||||
const gesehen: string[] = [];
|
||||
bus.subscribe(() => { throw new Error('Verbindung weg'); });
|
||||
bus.subscribe((ev) => { gesehen.push(ev.kind); });
|
||||
|
||||
bus.publish(nachricht());
|
||||
assert.deepEqual(gesehen, ['chat']);
|
||||
});
|
||||
|
||||
test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
|
||||
const bus = createBus();
|
||||
let n = 0;
|
||||
const ab = bus.subscribe(() => { n++; });
|
||||
bus.publish(nachricht());
|
||||
ab();
|
||||
bus.publish(nachricht());
|
||||
|
||||
assert.equal(n, 1);
|
||||
assert.equal(bus.clients(), 0);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Dauerbetrieb mit erfundenen Ereignissen: erfundener Chat und alle paar
|
||||
// Sekunden eine Einblendung.
|
||||
//
|
||||
// Fuer `abdulive overlay --demo` und fuer den Knopf "Vorfuehrung" auf der
|
||||
// Uebersichtsseite. Die einzelnen Ereignisse kommen aus test-events.ts - die
|
||||
// Knoepfe und der Dauerbetrieb sollen dasselbe zeigen.
|
||||
|
||||
import { logger } from '#core/logger.ts';
|
||||
import type { Bus } from './bus.ts';
|
||||
import { testEvent, zufall, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay:demo');
|
||||
|
||||
const ALARME: TestArt[] = ['sub', 'resub', 'subgift', 'massgift', 'raid', 'cheer', 'first'];
|
||||
const NACHRICHTEN: TestArt[] = ['chat', 'chat', 'chat', 'chat-emote', 'chat-emote', 'chat-erstmals'];
|
||||
|
||||
/**
|
||||
* Speist den Bus, bis `signal` abbricht.
|
||||
*
|
||||
* Der Stream-Zustand wird mitgesetzt, damit Statusleiste und Pausenbild etwas
|
||||
* zu zeigen haben. Genau deshalb darf das nicht laufen, waehrend ein echter
|
||||
* Stream laeuft - der Aufrufer prueft das.
|
||||
*/
|
||||
export function startDemo(bus: Bus, signal: AbortSignal): void {
|
||||
log.info('Vorfuehrmodus: erfundener Chat und alle paar Sekunden eine Einblendung');
|
||||
|
||||
let viewers = 17;
|
||||
const seit = new Date(Date.now() - 74 * 60_000).toISOString();
|
||||
|
||||
const stand = (): void => {
|
||||
viewers = Math.max(3, viewers + Math.round((Math.random() - 0.45) * 4));
|
||||
bus.publish({
|
||||
kind: 'stream', live: true,
|
||||
title: 'Elden Ring, aber nur mit Fäusten - Tag 3',
|
||||
category: 'Elden Ring',
|
||||
viewers, startedAt: seit,
|
||||
});
|
||||
};
|
||||
stand();
|
||||
|
||||
const sende = (art: TestArt): void => {
|
||||
const ev = testEvent(art);
|
||||
if (ev) bus.publish(ev);
|
||||
};
|
||||
|
||||
const chat = setInterval(() => sende(zufall(NACHRICHTEN)), 1400);
|
||||
const alarm = setInterval(() => sende(zufall(ALARME)), 9000);
|
||||
const puls = setInterval(stand, 20_000);
|
||||
// Ab und zu eine Chat-Spitze, damit der Emote-Regen auch mal laeuft.
|
||||
const spitze = setInterval(() => sende('hype'), 31_000);
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
clearInterval(chat); clearInterval(alarm); clearInterval(puls); clearInterval(spitze);
|
||||
log.info('Vorfuehrmodus beendet');
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
// Der einzige Server, der von aussen erreichbar ist.
|
||||
//
|
||||
// Er macht drei Dinge unter einer Adresse, weil ein zweiter oeffentlicher
|
||||
// Port ein zweiter Eintrag im Reverse-Proxy und ein zweites Zertifikat waere:
|
||||
//
|
||||
// / Uebersicht mit den Adressen fuer OBS (Passwort)
|
||||
// /setup Einrichtung: Twitch-Apps, Tokens, Discord (Passwort)
|
||||
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
|
||||
// /alerts ... die Browser-Sources (Schluessel)
|
||||
// /events der SSE-Strom dahinter (Schluessel)
|
||||
//
|
||||
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
|
||||
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
|
||||
// ADR-0005, und der Schutz wandert dafuer von "nur localhost" auf Passwort
|
||||
// und Schluessel. Ohne PUBLIC_URL bleibt es bei 127.0.0.1.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||
import { createZugang, type Zugang } from './auth.ts';
|
||||
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||
import { startDemo } from './demo.ts';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
const log = logger('overlay');
|
||||
|
||||
const WEB_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'overlay-web', 'public');
|
||||
|
||||
/**
|
||||
* Herzschlag. OBS' Browser-Source haelt eine tote Verbindung sonst fuer
|
||||
* lebendig, bis die naechste Chatnachricht kommt - und wenn der Chat still
|
||||
* ist, ist das eine Stunde.
|
||||
*/
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
|
||||
export const PAGES = [
|
||||
{ path: '/alerts', file: 'alerts.html', w: 1920, h: 1080,
|
||||
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
||||
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
||||
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||
title: 'Effekte', hint: 'Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids.' },
|
||||
{ path: '/starting', file: 'starting.html', w: 1920, h: 1080,
|
||||
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
|
||||
{ path: '/tts', file: 'tts.html', w: 600, h: 200,
|
||||
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
|
||||
] as const;
|
||||
|
||||
export type OverlayServer = {
|
||||
bus: Bus;
|
||||
zugang: Zugang;
|
||||
close: () => void;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type OverlayOptions = {
|
||||
port: number;
|
||||
channel: string;
|
||||
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
|
||||
publicUrl: string;
|
||||
bus?: Bus;
|
||||
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
|
||||
setup?: ServerDeps;
|
||||
/**
|
||||
* Die Freigabe wartender Vorlese-Nachrichten. Der Hub reicht sie herein;
|
||||
* ohne sie zeigt die Seite den Abschnitt gar nicht erst an.
|
||||
*/
|
||||
tts?: {
|
||||
wartend: () => Array<{ id: string; name: string; text: string; seit: string }>;
|
||||
freigeben: (id: string) => boolean;
|
||||
ablehnen: (id: string) => boolean;
|
||||
alleAblehnen: () => number;
|
||||
still: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
const bus = opts.bus ?? createBus();
|
||||
const zugang = createZugang();
|
||||
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
|
||||
// den ersten ersetzen, nicht danebenlaufen.
|
||||
const vorfuehrung = { abbruch: null as AbortController | null };
|
||||
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
|
||||
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => {
|
||||
log.error(`Anfrage fehlgeschlagen: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Interner Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(opts.port, host, () => {
|
||||
log.info(`Overlay-Server laeuft auf ${host}:${opts.port}` +
|
||||
(opts.publicUrl ? ` (oeffentlich: ${opts.publicUrl})` : ''));
|
||||
if (!zugang.hatPasswort() && opts.publicUrl) {
|
||||
log.warn('Es ist kein Passwort gesetzt - die Einrichtungsseite steht offen. ' +
|
||||
'Setzen mit: abdulive web:passwort');
|
||||
}
|
||||
});
|
||||
server.on('error', (err) => {
|
||||
log.error(`Overlay-Server nicht gestartet: ${err.message}` +
|
||||
((err as NodeJS.ErrnoException).code === 'EADDRINUSE'
|
||||
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
|
||||
});
|
||||
|
||||
return { bus, zugang, close: () => server.close(), port: opts.port };
|
||||
}
|
||||
|
||||
type Vorfuehrung = { abbruch: AbortController | null };
|
||||
|
||||
async function handle(
|
||||
req: http.IncomingMessage, res: http.ServerResponse,
|
||||
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung
|
||||
): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
|
||||
const p = url.pathname;
|
||||
|
||||
// ---- Anmeldung. Bewusst vor allem anderen.
|
||||
if (p === '/login' && req.method === 'POST') {
|
||||
const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? '');
|
||||
if (!cookie) return redirect(res, '/?fehler=1');
|
||||
res.writeHead(303, { location: '/', 'set-cookie': cookie });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (p === '/logout') {
|
||||
res.writeHead(303, { location: '/', 'set-cookie': zugang.abmelden() });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Der Rueckruf von Twitch. Kein Passwort: Twitch schickt den Browser
|
||||
// hierher zurueck, und der `state`-Wert ist der Schutz.
|
||||
if (p === '/callback' && opts.setup) {
|
||||
if (await setupRoutes(req, res, opts.setup, url)) return;
|
||||
}
|
||||
|
||||
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
|
||||
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
|
||||
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
|
||||
|
||||
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
||||
if (page || p === '/events') {
|
||||
if (!zugang.darfOverlay(req, url)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8',
|
||||
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||
'Sie steht fertig auf der Uebersichtsseite.');
|
||||
}
|
||||
if (p === '/events') return sse(req, res, bus);
|
||||
return sendFile(res, page!.file);
|
||||
}
|
||||
|
||||
// ---- Ab hier nur mit Anmeldung.
|
||||
if (!zugang.istAngemeldet(req)) {
|
||||
return send(res, 401, 'text/html; charset=utf-8',
|
||||
loginPage(opts.channel, url.searchParams.has('fehler')));
|
||||
}
|
||||
|
||||
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
|
||||
// davon landet in der Datenbank, auf Discord oder bei Twitch.
|
||||
if (p === '/test' && req.method === 'POST') {
|
||||
const form = await readForm(req);
|
||||
const art = (form.get('art') ?? '') as TestArt | 'demo-an' | 'demo-aus';
|
||||
const laeuftStream = zustand(bus).live;
|
||||
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, demo: vorfuehrung.abbruch !== null }));
|
||||
|
||||
if (art === 'demo-aus') {
|
||||
vorfuehrung.abbruch?.abort();
|
||||
vorfuehrung.abbruch = null;
|
||||
// Der letzte erfundene Stand bleibt sonst im Bild stehen.
|
||||
bus.publish({ kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null });
|
||||
return antwort('Vorführung gestoppt.');
|
||||
}
|
||||
|
||||
if (art === 'demo-an') {
|
||||
if (laeuftStream) {
|
||||
return antwort('Nicht während eines Streams: die Vorführung überschreibt ' +
|
||||
'Titel, Kategorie und Zuschauerzahl in der Statusleiste.', 409);
|
||||
}
|
||||
vorfuehrung.abbruch?.abort();
|
||||
vorfuehrung.abbruch = new AbortController();
|
||||
startDemo(bus, vorfuehrung.abbruch.signal);
|
||||
return antwort('Vorführung läuft.');
|
||||
}
|
||||
|
||||
const knopf = TEST_KNOEPFE.find((k) => k.art === art);
|
||||
if (!knopf) return antwort('Unbekannter Knopf.', 400);
|
||||
if (knopf.nurOffline && laeuftStream) {
|
||||
return antwort('Gesperrt, solange der Stream läuft - das würde in der ' +
|
||||
'Statusleiste stehen bleiben.', 409);
|
||||
}
|
||||
|
||||
// Die neueste Nachricht aus dem Rueckblick, damit "letzte löschen" etwas
|
||||
// trifft, das auch wirklich im Bild steht.
|
||||
const chat = zustand(bus).chat;
|
||||
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
||||
bus.publish(ev);
|
||||
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||
return antwort(`${knopf.label} ausgelöst.`);
|
||||
}
|
||||
|
||||
// ---- Vorlese-Warteschlange
|
||||
if (p === '/tts' && req.method === 'POST' && opts.tts) {
|
||||
const form = await readForm(req);
|
||||
const was = form.get('was') ?? '';
|
||||
const id = form.get('id') ?? '';
|
||||
const antwort = (text: string, code = 200): void =>
|
||||
send(res, code, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text, wartend: opts.tts!.wartend() }));
|
||||
|
||||
if (was === 'still') { opts.tts.still(); return antwort('Alles still.'); }
|
||||
if (was === 'alle-ablehnen') {
|
||||
const n = opts.tts.alleAblehnen();
|
||||
return antwort(`${n} Nachricht(en) abgelehnt.`);
|
||||
}
|
||||
if (was === 'freigeben') {
|
||||
return antwort(opts.tts.freigeben(id) ? 'Wird vorgelesen.' : 'Nicht mehr da.');
|
||||
}
|
||||
if (was === 'ablehnen') {
|
||||
return antwort(opts.tts.ablehnen(id) ? 'Abgelehnt.' : 'Nicht mehr da.');
|
||||
}
|
||||
return antwort('Unbekannt.', 400);
|
||||
}
|
||||
|
||||
if (p === '/tts/wartend' && opts.tts) {
|
||||
return send(res, 200, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ wartend: opts.tts.wartend() }));
|
||||
}
|
||||
|
||||
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerOverlayKey();
|
||||
return redirect(res, '/');
|
||||
}
|
||||
|
||||
if (p === '/' || p === '/index.html') {
|
||||
return send(res, 200, 'text/html; charset=utf-8',
|
||||
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null));
|
||||
}
|
||||
|
||||
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
|
||||
|
||||
send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden. Die Uebersicht steht auf /');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- SSE
|
||||
|
||||
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
// Ohne das puffert so mancher Proxy den Strom, und die Einblendung kommt
|
||||
// gesammelt nach zwei Minuten. Nginx beachtet genau diesen Kopf.
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
|
||||
const write = (ev: OverlayEvent): void => {
|
||||
// `retry` sagt dem Browser, wie schnell er neu verbinden soll. Vorgabe
|
||||
// waeren drei Sekunden; nach einem Neustart mitten im Stream ist eine
|
||||
// Sekunde besser, und mehr kostet es hier nicht.
|
||||
res.write(`retry: 1000\ndata: ${JSON.stringify(ev)}\n\n`);
|
||||
};
|
||||
|
||||
// Erst anmelden, dann den Schnappschuss: andersherum wuerde ein Ereignis,
|
||||
// das genau dazwischen faellt, verloren gehen.
|
||||
const unsubscribe = bus.subscribe(write);
|
||||
write(bus.snapshot());
|
||||
|
||||
// Als echtes Ereignis, nicht als SSE-Kommentar: ein Kommentar haelt die
|
||||
// Verbindung zwar offen, ist im Browser aber unsichtbar. Die Seite soll
|
||||
// merken koennen, dass laengst nichts mehr kam, und selbst neu verbinden.
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ kind: 'beat', t: Date.now() })}\n\n`),
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const end = (): void => { clearInterval(beat); unsubscribe(); };
|
||||
req.on('close', end);
|
||||
res.on('error', end);
|
||||
}
|
||||
|
||||
/** Der aktuelle Stand aus dem Bus, ohne den Typ an jeder Stelle aufzudroeseln. */
|
||||
function zustand(bus: Bus) {
|
||||
const s = bus.snapshot();
|
||||
if (s.kind !== 'state') throw new Error('Schnappschuss hat die falsche Form');
|
||||
return { live: s.stream.live, chat: s.chat };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeug
|
||||
|
||||
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > 16 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function redirect(res: http.ServerResponse, to: string): void {
|
||||
res.writeHead(303, { location: to });
|
||||
res.end();
|
||||
}
|
||||
|
||||
function send(res: http.ServerResponse, code: number, type: string, body: string | Buffer): void {
|
||||
res.writeHead(code, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
// Der Schluessel steht in der Adresse. Ohne diesen Kopf traegt ihn jeder
|
||||
// Bildaufruf zu Twitchs Emote-CDN im Referer mit hinaus.
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendFile(res: http.ServerResponse, rel: string): void {
|
||||
// Aufloesen und danach pruefen, ob das Ergebnis noch unter WEB_ROOT liegt.
|
||||
// `..` im Pfad ist der Klassiker, und ein Server, der beliebige Dateien
|
||||
// ausliefert, liefert irgendwann secrets.json aus.
|
||||
const file = path.resolve(WEB_ROOT, rel);
|
||||
if (!file.startsWith(WEB_ROOT + path.sep)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8', 'Nicht erlaubt');
|
||||
}
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = fs.readFileSync(file);
|
||||
} catch {
|
||||
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
|
||||
}
|
||||
send(res, 200, TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Seiten
|
||||
|
||||
const SEITEN_CSS = `
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 2.5rem clamp(1rem, 5vw, 4rem); background: #0d0b14; color: #e9e6f3;
|
||||
font: 16px/1.6 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; }
|
||||
h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h1 span { color: #a970ff; }
|
||||
p.lead { color: #9b94b3; margin: 0 0 2rem; max-width: 62ch; }
|
||||
table { border-collapse: collapse; width: 100%; max-width: 74rem; }
|
||||
td { padding: .9rem 1rem; border-top: 1px solid #241f35; vertical-align: top; }
|
||||
tr:first-child td { border-top: 0; }
|
||||
.hint { color: #8b84a3; font-size: .875rem; }
|
||||
.size { color: #8b84a3; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
code { background: #171327; padding: .25rem .5rem; border-radius: .4rem; word-break: break-all;
|
||||
font-family: "Cascadia Mono", Consolas, monospace; font-size: .8rem; }
|
||||
button, a.knopf { font: inherit; font-size: .85rem; margin-left: .5rem; background: none;
|
||||
border: 1px solid #3a3352; color: #c9c2e0; border-radius: .4rem;
|
||||
padding: .2rem .6rem; cursor: pointer; text-decoration: none; }
|
||||
button:hover, a.knopf:hover { border-color: #a970ff; color: #fff; }
|
||||
input { font: inherit; background: #171327; border: 1px solid #3a3352; color: #e9e6f3;
|
||||
border-radius: .4rem; padding: .5rem .7rem; }
|
||||
.status { margin-top: 2rem; color: #8b84a3; font-size: .9rem; }
|
||||
.dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%;
|
||||
background: #ef4444; margin-right: .4rem; }
|
||||
.dot.on { background: #22c55e; }
|
||||
ol { max-width: 62ch; color: #9b94b3; }
|
||||
.warn { color: #ffc53d; }
|
||||
h2 { font-size: 1.1rem; margin: 2.5rem 0 .4rem; }
|
||||
.gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
|
||||
margin-bottom: .6rem; }
|
||||
.gruppenname { display: inline-block; min-width: 9rem; color: #8b84a3;
|
||||
font-size: .85rem; }
|
||||
button.test { margin-left: 0; }
|
||||
button.test:disabled { opacity: .35; cursor: not-allowed; border-color: #241f35; }
|
||||
.warnbox { border: 1px solid #ffc53d; color: #ffc53d; border-radius: .5rem;
|
||||
padding: .6rem .9rem; margin: .8rem 0; max-width: 62ch; }
|
||||
.meldung { min-height: 1.5rem; font-size: .9rem; color: #8b84a3; margin-top: .8rem; }
|
||||
.meldung.ok { color: #4ade80; }
|
||||
.meldung.fehler { color: #ffc53d; }
|
||||
.wartend { display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
|
||||
.wartend:first-child { border-top: 0; }
|
||||
.wartend .hint { max-width: 60ch; word-break: break-word; }
|
||||
button.tts { margin-left: .4rem; }
|
||||
`;
|
||||
|
||||
export function loginPage(channel: string, fehler: boolean): string {
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Anmelden</title><style>${SEITEN_CSS}
|
||||
body { display: grid; place-items: center; height: 100vh; padding: 0; }
|
||||
form { text-align: left; }
|
||||
</style>
|
||||
<form method="post" action="/login">
|
||||
<h1>abdulive <span>${escape(channel)}</span></h1>
|
||||
<p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p>
|
||||
<input type="password" name="passwort" autofocus autocomplete="current-password" size="30">
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Seite, die man aufmacht, bevor man OBS einrichtet. Sie ist bewusst hier
|
||||
* und nicht in public/: sie kennt die oeffentliche Adresse, den Schluessel und
|
||||
* die Seitenliste, und das soll nicht an zwei Stellen stehen.
|
||||
*/
|
||||
export function indexPage(
|
||||
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false
|
||||
): string {
|
||||
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
|
||||
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
|
||||
const ttsFeld = opts.tts ? `
|
||||
<h2>Vorlesen: wartet auf Freigabe</h2>
|
||||
<p class="lead">Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die
|
||||
Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
|
||||
<div id="wartend"><span class="hint">wird geladen …</span></div>
|
||||
<div class="gruppe" style="margin-top:.8rem">
|
||||
<button class="tts" data-was="alle-ablehnen">Alle ablehnen</button>
|
||||
<button class="tts" data-was="still">Sofort still</button>
|
||||
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
|
||||
</div>
|
||||
<p id="tts-meldung" class="meldung"> </p>` : '';
|
||||
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
|
||||
const rows = PAGES.map((p) => {
|
||||
const id = `u${p.path.slice(1)}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${p.title}</strong><div class="hint">${p.hint}</div></td>
|
||||
<td class="size">${p.w} × ${p.h}</td>
|
||||
<td>
|
||||
<code id="${id}">${base}${p.path}?key=${key}</code>
|
||||
<button data-copy="${id}">kopieren</button>
|
||||
<a class="knopf" href="${p.path}?probe=1" target="_blank" rel="noreferrer">ansehen</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const;
|
||||
const testfeld = gruppen.map((g) => `
|
||||
<div class="gruppe">
|
||||
<span class="gruppenname">${g}</span>
|
||||
${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) =>
|
||||
`<button class="test" data-art="${k.art}"${k.nurOffline ? ' data-offline="1"' : ''}>${k.label}</button>`
|
||||
).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
return `<!doctype html><html lang="de"><meta charset="utf-8">
|
||||
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style>
|
||||
<h1>Overlay <span>${escape(opts.channel)}</span></h1>
|
||||
<p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
|
||||
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
|
||||
verpasst das Overlay alles, was in einer anderen Szene passiert.</p>
|
||||
<table>${rows}</table>
|
||||
<p class="status"><span class="dot" id="dot"></span><span id="txt">verbinde ...</span>
|
||||
· ${bus.clients()} verbundene Quelle(n)
|
||||
${opts.setup ? ' · <a class="knopf" href="/setup">Einrichtung</a>' +
|
||||
'<a class="knopf" href="/health">Status</a>' : ''}
|
||||
<a class="knopf" href="/logout">Abmelden</a>
|
||||
</p>
|
||||
<h2>Ausprobieren</h2>
|
||||
<p class="lead">Löst ein erfundenes Ereignis aus. Es geht nur an die
|
||||
Browser-Sources - nichts davon landet in der Datenbank, auf Discord oder bei
|
||||
Twitch. Am besten die Seiten in einem zweiten Fenster offen haben
|
||||
(„ansehen“ oben).</p>
|
||||
<div id="live-warnung" class="warnbox" hidden>
|
||||
Der Stream läuft gerade. Was du hier auslöst, sehen die Zuschauer.
|
||||
</div>
|
||||
${testfeld}
|
||||
<div class="gruppe">
|
||||
<span class="gruppenname">Dauerbetrieb</span>
|
||||
<button class="test" id="demo" data-art="${demoLaeuft ? 'demo-aus' : 'demo-an'}">
|
||||
${demoLaeuft ? 'Vorführung stoppen' : 'Vorführung starten'}
|
||||
</button>
|
||||
<span class="hint">Erfundener Chat und alle paar Sekunden eine Einblendung,
|
||||
bis du wieder stoppst.</span>
|
||||
</div>
|
||||
<p id="meldung" class="meldung"> </p>
|
||||
|
||||
${ttsFeld}
|
||||
|
||||
<h2>Schlüssel</h2>
|
||||
<form method="post" action="/schluessel/neu">
|
||||
<button type="submit">Schlüssel neu würfeln</button>
|
||||
<span class="hint">Danach müssen alle Adressen in OBS neu eingetragen werden.</span>
|
||||
</form>
|
||||
<ol>
|
||||
<li>Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.</li>
|
||||
<li>Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.</li>
|
||||
<li>Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.</li>
|
||||
</ol>
|
||||
<script>
|
||||
for (const b of document.querySelectorAll('button[data-copy]')) {
|
||||
b.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText(document.getElementById(b.dataset.copy).textContent);
|
||||
b.textContent = 'kopiert';
|
||||
setTimeout(() => { b.textContent = 'kopieren'; }, 1200);
|
||||
});
|
||||
}
|
||||
const es = new EventSource('/events');
|
||||
const dot = document.getElementById('dot'), txt = document.getElementById('txt');
|
||||
es.onopen = () => { dot.classList.add('on'); txt.textContent = 'verbunden'; };
|
||||
es.onerror = () => { dot.classList.remove('on'); txt.textContent = 'getrennt, versuche es erneut'; };
|
||||
const warnung = document.getElementById('live-warnung');
|
||||
const meldung = document.getElementById('meldung');
|
||||
|
||||
function setzeLive(live) {
|
||||
warnung.hidden = !live;
|
||||
// Knöpfe, die einen Zustand setzen, bleiben während eines Streams aus:
|
||||
// sie würden in der Statusleiste stehen bleiben.
|
||||
for (const b of document.querySelectorAll('button.test[data-offline]')) b.disabled = live;
|
||||
const demo = document.getElementById('demo');
|
||||
if (demo.dataset.art === 'demo-an') demo.disabled = live;
|
||||
}
|
||||
|
||||
es.onmessage = (e) => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.kind === 'state') {
|
||||
txt.textContent = ev.stream.live
|
||||
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
|
||||
: 'verbunden - kein Stream';
|
||||
setzeLive(ev.stream.live);
|
||||
} else if (ev.kind === 'stream') {
|
||||
setzeLive(ev.live);
|
||||
}
|
||||
};
|
||||
|
||||
let sagAb;
|
||||
async function loese(art) {
|
||||
const res = await fetch('/test', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ art }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
|
||||
meldung.textContent = body.text;
|
||||
meldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
|
||||
clearTimeout(sagAb);
|
||||
sagAb = setTimeout(() => { meldung.innerHTML = ' '; meldung.className = 'meldung'; }, 4000);
|
||||
|
||||
const demo = document.getElementById('demo');
|
||||
demo.dataset.art = body.demo ? 'demo-aus' : 'demo-an';
|
||||
demo.textContent = body.demo ? 'Vorführung stoppen' : 'Vorführung starten';
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll('button.test')) {
|
||||
b.addEventListener('click', () => loese(b.dataset.art));
|
||||
}
|
||||
|
||||
const wartendEl = document.getElementById('wartend');
|
||||
if (wartendEl) {
|
||||
const ttsMeldung = document.getElementById('tts-meldung');
|
||||
|
||||
function zeichne(liste) {
|
||||
if (!liste.length) {
|
||||
wartendEl.innerHTML = '<span class="hint">Nichts wartet.</span>';
|
||||
return;
|
||||
}
|
||||
wartendEl.innerHTML = liste.map((w) => \`
|
||||
<div class="wartend">
|
||||
<div>
|
||||
<strong>\${w.name}</strong>
|
||||
<div class="hint">\${w.text}</div>
|
||||
</div>
|
||||
<div style="white-space:nowrap">
|
||||
<button class="tts" data-was="freigeben" data-id="\${w.id}">Vorlesen</button>
|
||||
<button class="tts" data-was="ablehnen" data-id="\${w.id}">Ablehnen</button>
|
||||
</div>
|
||||
</div>\`).join('');
|
||||
}
|
||||
|
||||
async function ttsRuf(was, id) {
|
||||
const res = await fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ was, id: id || '' }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status, wartend: [] }));
|
||||
ttsMeldung.textContent = body.text || '';
|
||||
zeichne(body.wartend || []);
|
||||
setTimeout(() => { ttsMeldung.innerHTML = ' '; }, 4000);
|
||||
}
|
||||
|
||||
wartendEl.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button.tts');
|
||||
if (b) ttsRuf(b.dataset.was, b.dataset.id);
|
||||
});
|
||||
for (const b of document.querySelectorAll('.gruppe button.tts')) {
|
||||
b.addEventListener('click', () => ttsRuf(b.dataset.was));
|
||||
}
|
||||
|
||||
// Kurzer Takt: eine Einlösung soll nicht erst nach einem Neuladen
|
||||
// auftauchen. Kostet nichts, die Liste ist winzig.
|
||||
const laden = () => fetch('/tts/wartend')
|
||||
.then((r) => r.json()).then((b) => zeichne(b.wartend || [])).catch(() => {});
|
||||
laden();
|
||||
setInterval(laden, 3000);
|
||||
}
|
||||
</script>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]!));
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Die Testknoepfe sind das einzige Werkzeug, mit dem sich das Overlay
|
||||
// ausserhalb eines Streams pruefen laesst. Geht hier etwas kaputt, merkt man
|
||||
// es ausgerechnet dann nicht, wenn man es am noetigsten braucht.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
|
||||
|
||||
test('zu jedem Knopf gibt es ein Ereignis', () => {
|
||||
// Ein Knopf ohne Ereignis tut beim Druecken nichts und sieht dabei aus wie
|
||||
// ein kaputtes Overlay.
|
||||
for (const k of TEST_KNOEPFE) {
|
||||
if (k.art === 'chat-loeschen') continue; // braucht eine Nachricht, siehe unten
|
||||
assert.ok(testEvent(k.art), `${k.art} liefert kein Ereignis`);
|
||||
}
|
||||
});
|
||||
|
||||
test('jeder Knopf kommt nur einmal vor', () => {
|
||||
const arten = TEST_KNOEPFE.map((k) => k.art);
|
||||
assert.equal(new Set(arten).size, arten.length);
|
||||
});
|
||||
|
||||
test('die Einblendungen tragen die Felder, die die Seite anzeigt', () => {
|
||||
const raid = testEvent('raid') as { kind: string; type: string; amount: number };
|
||||
assert.equal(raid.kind, 'alert');
|
||||
assert.equal(raid.type, 'raid');
|
||||
assert.ok(raid.amount > 0, 'ohne Zuschauerzahl steht "mit undefined Zuschauern" im Bild');
|
||||
|
||||
const gift = testEvent('subgift') as { recipient?: string };
|
||||
assert.ok(gift.recipient, 'ohne Beschenkten fehlt die zweite Zeile');
|
||||
|
||||
const resub = testEvent('resub') as { months?: number };
|
||||
assert.ok(resub.months && resub.months > 0);
|
||||
});
|
||||
|
||||
test('"letzte löschen" braucht eine Nachricht und trifft genau die', () => {
|
||||
assert.equal(testEvent('chat-loeschen'), null, 'ohne Chat gibt es nichts zu loeschen');
|
||||
assert.deepEqual(testEvent('chat-loeschen', 'abc'), { kind: 'clear', messageId: 'abc' });
|
||||
});
|
||||
|
||||
test('"Chat leeren" loescht alles, nicht nur eine Nachricht', () => {
|
||||
assert.deepEqual(testEvent('chat-leeren'), { kind: 'clear' });
|
||||
});
|
||||
|
||||
test('Chatnachrichten haben eine eigene ID', () => {
|
||||
// Zwei Nachrichten mit derselben ID wuerden sich beim Loeschen gegenseitig
|
||||
// mitnehmen.
|
||||
const a = testEvent('chat') as { id: string };
|
||||
const b = testEvent('chat') as { id: string };
|
||||
assert.notEqual(a.id, b.id);
|
||||
});
|
||||
|
||||
test('das Emote im Test ist im Text auch enthalten', () => {
|
||||
// Sonst wird nichts ersetzt und der Knopf prueft die Emote-Anzeige nicht.
|
||||
const ev = testEvent('chat-emote') as { text: string; emotes: Array<{ name: string }> };
|
||||
assert.equal(ev.emotes.length, 1);
|
||||
assert.ok(ev.text.includes(ev.emotes[0]!.name));
|
||||
});
|
||||
|
||||
test('gesperrt ist nur, was einen falschen Stand setzt - nicht der Weg zurueck', () => {
|
||||
// Eine Einblendung blitzt auf und ist weg. Ein erfundener Stream-Zustand
|
||||
// bleibt in der Statusleiste stehen, bis die naechste Abfrage ihn
|
||||
// geraderueckt - und das dauert bis zu einer Minute.
|
||||
//
|
||||
// "Zuruecksetzen" darf dabei nie gesperrt sein: sonst kommt man aus einem
|
||||
// erfundenen Zustand ohne Neustart nicht mehr heraus, und auf einem Hub ohne
|
||||
// Broadcaster-Token raeumt ueberhaupt niemand hinterher auf.
|
||||
const gesperrt = TEST_KNOEPFE.filter((k) => k.nurOffline).map((k) => k.art);
|
||||
assert.deepEqual(gesperrt, ['live-an']);
|
||||
|
||||
const zurueck = TEST_KNOEPFE.find((k) => k.art === 'live-aus');
|
||||
assert.equal(zurueck?.nurOffline, undefined, 'der Weg zurueck bleibt immer offen');
|
||||
});
|
||||
|
||||
test('der Stand-Knopf setzt einen anzeigbaren Stream', () => {
|
||||
const an = testEvent('live-an') as
|
||||
{ kind: string; live: boolean; title: string; startedAt: string | null };
|
||||
assert.equal(an.kind, 'stream');
|
||||
assert.equal(an.live, true);
|
||||
assert.ok(an.title.length > 0);
|
||||
assert.ok(an.startedAt && Date.parse(an.startedAt) < Date.now(), 'Laufzeit muss positiv sein');
|
||||
|
||||
const aus = testEvent('live-aus') as { live: boolean; startedAt: string | null };
|
||||
assert.equal(aus.live, false);
|
||||
assert.equal(aus.startedAt, null);
|
||||
});
|
||||
|
||||
test('unbekannte Knoepfe liefern nichts, statt etwas zu erfinden', () => {
|
||||
assert.equal(testEvent('gibtsnicht' as TestArt), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Kanalpunkte
|
||||
|
||||
test('eine Belohnung wird nie als Abo eingeblendet', () => {
|
||||
// Genau das stand hier einmal: die Konfetti-Belohnung hat die Abo-
|
||||
// Einblendung wiederverwendet und "Neues Abo" ins Bild geschrieben. Eine
|
||||
// Falschmeldung im Stream, die in der Aufzeichnung bleibt.
|
||||
const e = testEvent('reward') as { kind: string; type: string; reward?: string };
|
||||
assert.equal(e.kind, 'alert');
|
||||
assert.equal(e.type, 'reward');
|
||||
assert.notEqual(e.type, 'sub');
|
||||
assert.ok(e.reward, 'ohne Titel weiss niemand, welche Belohnung gemeint ist');
|
||||
});
|
||||
|
||||
test('Konfetti ist ein eigenes Ereignis, keine Nebenwirkung einer Einblendung', () => {
|
||||
// So muss die Effektseite nicht wissen, welche Belohnung was ausloest.
|
||||
const e = testEvent('reward-konfetti') as { kind: string; staerke: number };
|
||||
assert.equal(e.kind, 'konfetti');
|
||||
assert.ok(e.staerke > 0);
|
||||
});
|
||||
|
||||
test('der Vorlese-Knopf schickt Text, den die Seite auch sprechen kann', () => {
|
||||
const e = testEvent('tts') as { kind: string; text: string; name: string };
|
||||
assert.equal(e.kind, 'tts');
|
||||
assert.ok(e.text.length > 0);
|
||||
assert.ok(e.name.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// Erfundene Ereignisse zum Ausprobieren, für die Knöpfe auf der
|
||||
// Übersichtsseite und für den Vorführmodus.
|
||||
//
|
||||
// Warum das gebraucht wird: ein Raid kommt vielleicht einmal die Woche vor.
|
||||
// Darauf zu warten, um zu sehen, ob die Einblendung passt, ist keine
|
||||
// Arbeitsweise - und mitten im Stream herauszufinden, dass der Text abgeschnitten
|
||||
// wird, ist die schlechteste aller Möglichkeiten.
|
||||
//
|
||||
// Die Knopfliste und die Ereignisse stehen hier zusammen, damit die Seite die
|
||||
// Knöpfe daraus bauen kann. Ein Knopf, den es gibt, hat damit immer auch ein
|
||||
// Ereignis dahinter.
|
||||
|
||||
import { ulid } from '#shared/events.ts';
|
||||
import type { ChatEvent, OverlayEvent } from './bus.ts';
|
||||
|
||||
export const NAMEN = [
|
||||
'kevin_ohne_c', 'Melli', 'HansWurst42', 'dieNina', 'Tobi', 'Schnitzelfan',
|
||||
'pixelpudel', 'Rewe_Kassiererin', 'Olaf', 'zwiebeljule',
|
||||
];
|
||||
|
||||
export const SPRUECHE = [
|
||||
'moin', 'was geht', 'der Boss hat 3 Phasen oder?', 'hahaha',
|
||||
'rechts! RECHTS!', 'ich guck seit ner Stunde zu, richtig gut',
|
||||
'kann jemand den Titel lesen', 'das war knapp', 'gg',
|
||||
'mein Internet ist heute wieder Müll', 'hast du den Trank noch?',
|
||||
'erster', 'wie lange streamst du heute noch',
|
||||
];
|
||||
|
||||
/**
|
||||
* Globale Twitch-Emotes mit ihren echten IDs - nachgeschlagen, nicht geraten,
|
||||
* damit die Bilder wirklich laden. KEKW fehlt hier absichtlich: das ist ein
|
||||
* BTTV-Emote und hat bei Twitch keine ID.
|
||||
*/
|
||||
export const EMOTES = [
|
||||
{ id: '425618', name: 'LUL', count: 1 },
|
||||
{ id: '25', name: 'Kappa', count: 1 },
|
||||
{ id: '354', name: '4Head', count: 1 },
|
||||
];
|
||||
|
||||
export const FARBEN = ['#ff4fa3', '#4fd1c5', '#a970ff', '#ffc53d', '#4ade80', null];
|
||||
|
||||
export function zufall<T>(list: readonly T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)]!;
|
||||
}
|
||||
|
||||
export type TestArt =
|
||||
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'cheer' | 'first' | 'clip' | 'follow'
|
||||
| 'reward' | 'reward-konfetti' | 'tts'
|
||||
| 'chat' | 'chat-emote' | 'chat-gif' | 'chat-erstmals' | 'chat-bits' | 'chat-lang'
|
||||
| 'chat-loeschen' | 'chat-leeren'
|
||||
| 'hype' | 'live-an' | 'live-aus';
|
||||
|
||||
export type TestKnopf = {
|
||||
art: TestArt;
|
||||
label: string;
|
||||
gruppe: 'Einblendungen' | 'Kanalpunkte' | 'Chat' | 'Effekte' | 'Stand';
|
||||
/**
|
||||
* Verändert den Zustand, den das Overlay dauerhaft anzeigt, statt nur einmal
|
||||
* aufzublitzen. Läuft gerade ein Stream, steht danach etwas Falsches im Bild,
|
||||
* bis die nächste Abfrage es geraderückt - solche Knöpfe sind gesperrt.
|
||||
*/
|
||||
nurOffline?: boolean;
|
||||
};
|
||||
|
||||
export const TEST_KNOEPFE: TestKnopf[] = [
|
||||
{ art: 'sub', label: 'Abo', gruppe: 'Einblendungen' },
|
||||
{ art: 'resub', label: 'Abo verlängert', gruppe: 'Einblendungen' },
|
||||
{ art: 'subgift', label: 'Abo verschenkt', gruppe: 'Einblendungen' },
|
||||
{ art: 'massgift', label: '5 Abos verschenkt', gruppe: 'Einblendungen' },
|
||||
{ art: 'raid', label: 'Raid', gruppe: 'Einblendungen' },
|
||||
{ art: 'cheer', label: 'Bits', gruppe: 'Einblendungen' },
|
||||
{ art: 'first', label: 'Erstchatter', gruppe: 'Einblendungen' },
|
||||
{ art: 'clip', label: 'Clip gespeichert', gruppe: 'Einblendungen' },
|
||||
{ art: 'follow', label: 'Follow', gruppe: 'Einblendungen' },
|
||||
|
||||
{ art: 'reward', label: 'Belohnung eingelöst', gruppe: 'Kanalpunkte' },
|
||||
{ art: 'reward-konfetti', label: 'Belohnung mit Konfetti', gruppe: 'Kanalpunkte' },
|
||||
{ art: 'tts', label: 'Vorlesen', gruppe: 'Kanalpunkte' },
|
||||
|
||||
{ art: 'chat', label: 'Nachricht', gruppe: 'Chat' },
|
||||
{ art: 'chat-emote', label: 'mit Emote', gruppe: 'Chat' },
|
||||
{ art: 'chat-gif', label: 'mit GIF', gruppe: 'Chat' },
|
||||
{ art: 'chat-erstmals', label: 'von einem Erstchatter', gruppe: 'Chat' },
|
||||
{ art: 'chat-bits', label: 'mit Bits', gruppe: 'Chat' },
|
||||
{ art: 'chat-lang', label: 'sehr lang', gruppe: 'Chat' },
|
||||
{ art: 'chat-loeschen', label: 'letzte löschen', gruppe: 'Chat' },
|
||||
{ art: 'chat-leeren', label: 'Chat leeren', gruppe: 'Chat' },
|
||||
|
||||
{ art: 'hype', label: 'Emote-Regen', gruppe: 'Effekte' },
|
||||
|
||||
{ art: 'live-an', label: 'Stream läuft', gruppe: 'Stand', nurOffline: true },
|
||||
// Absichtlich NICHT gesperrt: das ist der Weg aus einem erfundenen Zustand
|
||||
// wieder heraus. Wäre der Knopf gesperrt, sobald "Stream läuft" gedrückt
|
||||
// wurde, käme man ohne Neustart nicht mehr zurück - und auf einem Hub ohne
|
||||
// Broadcaster-Token räumt auch keine Abfrage hinterher auf.
|
||||
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
|
||||
];
|
||||
|
||||
function chat(over: Partial<ChatEvent> = {}): ChatEvent {
|
||||
const name = zufall(NAMEN);
|
||||
return {
|
||||
kind: 'chat',
|
||||
id: ulid(),
|
||||
userId: `test-${NAMEN.indexOf(name)}`,
|
||||
login: name.toLowerCase(),
|
||||
name,
|
||||
text: zufall(SPRUECHE),
|
||||
color: zufall(FARBEN),
|
||||
emotes: [],
|
||||
gifs: [],
|
||||
first: false,
|
||||
mod: Math.random() < 0.15,
|
||||
sub: Math.random() < 0.3,
|
||||
broadcaster: false,
|
||||
bits: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Ereignis zu einem Knopf.
|
||||
*
|
||||
* `letzteChatId` ist die neueste Nachricht im Rückblick des Busses - nur so
|
||||
* kann "letzte löschen" etwas treffen, das auch wirklich im Bild steht.
|
||||
*/
|
||||
export function testEvent(art: TestArt, letzteChatId?: string): OverlayEvent | null {
|
||||
const name = zufall(NAMEN);
|
||||
const alarm = (rest: Record<string, unknown>): OverlayEvent =>
|
||||
({ kind: 'alert', id: ulid(), name, ...rest } as OverlayEvent);
|
||||
|
||||
switch (art) {
|
||||
case 'sub': return alarm({ type: 'sub', tier: '1000' });
|
||||
case 'resub': return alarm({
|
||||
type: 'resub', months: 1 + Math.floor(Math.random() * 30),
|
||||
message: 'endlich wieder dabei, zwei Monate Pause waren genug',
|
||||
});
|
||||
case 'subgift': return alarm({ type: 'subgift', recipient: zufall(NAMEN) });
|
||||
case 'massgift': return alarm({ type: 'massgift', amount: 5 });
|
||||
case 'raid': return alarm({ type: 'raid', amount: 5 + Math.floor(Math.random() * 60) });
|
||||
case 'cheer': return alarm({
|
||||
type: 'cheer', amount: 100 * (1 + Math.floor(Math.random() * 10)),
|
||||
message: 'das musste sein',
|
||||
});
|
||||
case 'first': return alarm({ type: 'first' });
|
||||
case 'clip': return alarm({ type: 'clip' });
|
||||
case 'follow': return alarm({ type: 'follow' });
|
||||
case 'reward': return alarm({
|
||||
type: 'reward', reward: 'Nachricht anheften', amount: 500,
|
||||
message: 'guck mal links oben',
|
||||
});
|
||||
case 'reward-konfetti': return { kind: 'konfetti', staerke: 1 };
|
||||
case 'tts': return {
|
||||
kind: 'tts', id: ulid(), name,
|
||||
text: 'Das hier wird vorgelesen, sobald du es freigibst.',
|
||||
};
|
||||
|
||||
case 'chat': return chat();
|
||||
case 'chat-emote': {
|
||||
const e = zufall(EMOTES);
|
||||
return chat({ text: `${zufall(SPRUECHE)} ${e.name}`, emotes: [e] });
|
||||
}
|
||||
case 'chat-gif': {
|
||||
// Genau so, wie Twitch es schickt: der Text ist nur ein Platzhalter,
|
||||
// das Bild steckt im Tag. Echte Adresse, damit der Knopf auch wirklich
|
||||
// etwas anzeigt.
|
||||
const text = '[Idk GIF by Respective]';
|
||||
return chat({
|
||||
text,
|
||||
gifs: [{
|
||||
id: 'Pnd7qLFuGCOntZcNKB',
|
||||
url: 'https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif',
|
||||
from: 0, to: [...text].length - 1,
|
||||
}],
|
||||
});
|
||||
}
|
||||
case 'chat-erstmals': return chat({ first: true, text: 'moin, bin neu hier' });
|
||||
case 'chat-bits': return chat({ bits: 500, text: 'nimm die 500 Bits' });
|
||||
case 'chat-lang': return chat({
|
||||
// Der Fall, den man sonst erst im Stream sieht: eine Nachricht, die
|
||||
// den Kasten sprengt.
|
||||
text: 'also ich hab das gestern auch probiert und da ist mir aufgefallen ' +
|
||||
'dass der Boss in Phase zwei immer nach links ausweicht, aber nur wenn ' +
|
||||
'man vorher nicht geblockt hat, und das steht in keinem Guide drin',
|
||||
});
|
||||
case 'chat-loeschen':
|
||||
return letzteChatId ? { kind: 'clear', messageId: letzteChatId } : null;
|
||||
case 'chat-leeren': return { kind: 'clear' };
|
||||
|
||||
case 'hype': return { kind: 'hype', score: 4 + Math.random() * 4, rate: 45 };
|
||||
|
||||
case 'live-an': return {
|
||||
kind: 'stream', live: true,
|
||||
title: 'Probelauf des Overlays', category: 'Just Chatting',
|
||||
viewers: 12 + Math.floor(Math.random() * 30),
|
||||
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
|
||||
};
|
||||
case 'live-aus': return {
|
||||
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
|
||||
};
|
||||
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
+100
-2
@@ -1,5 +1,103 @@
|
||||
# @abdulive/overlay-web
|
||||
|
||||
Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel.
|
||||
Die Browser-Sources für OBS. Fünf Seiten, alle mit durchsichtigem Hintergrund,
|
||||
alle an demselben Ereignisstrom.
|
||||
|
||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||
Kein Build, kein Framework, keine Abhängigkeit: OBS lädt die Dateien direkt,
|
||||
und jede Zwischenstufe wäre ein Ding, das mitten im Stream kaputtgehen kann.
|
||||
Gemeinsam sind nur [`theme.css`](public/theme.css) (Farben, Schrift, Kontur)
|
||||
und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes).
|
||||
|
||||
## Die Seiten
|
||||
|
||||
| Adresse | Größe | Was sie zeigt |
|
||||
|---|---|---|
|
||||
| `/alerts` | 1920×1080 | Abos, Raids, Bits, Erstchatter. Eine nach der anderen, nie übereinander. |
|
||||
| `/chat` | 460×900 | Chatnachrichten mit Emotes, Abzeichen, Farben. |
|
||||
| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. |
|
||||
| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. |
|
||||
| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. |
|
||||
|
||||
Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`).
|
||||
|
||||
## In OBS
|
||||
|
||||
Je Seite eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe
|
||||
setzen. Zwei Häkchen sind wichtig:
|
||||
|
||||
- **"Quelle beim Unsichtbarwerden deaktivieren" aus.** Sonst verpasst das
|
||||
Overlay alles, was passiert, während eine andere Szene im Bild ist, und
|
||||
baut sich bei jedem Szenenwechsel neu auf.
|
||||
- **"Browser beim Szenenwechsel aktualisieren" aus.** Eine laufende
|
||||
Einblendung würde sonst mitten im Wechsel abgeschnitten.
|
||||
|
||||
## Einstellungen über die Adresse
|
||||
|
||||
Es gibt keine Konfigurationsdatei. Was einstellbar ist, hängt hinten an der
|
||||
URL - so lässt es sich in OBS ändern, ohne den Dienst anzufassen.
|
||||
|
||||
| Seite | Parameter | Vorgabe |
|
||||
|---|---|---|
|
||||
| alle | `probe=1` | dunkler Hintergrund zum Ansehen im Browser |
|
||||
| `/alerts` | `sekunden` | 7 - wie lange eine Einblendung steht |
|
||||
| `/alerts` | `erstchatter=0` | Erstchatter nicht einblenden |
|
||||
| `/chat` | `max` | 14 - so viele Zeilen gleichzeitig |
|
||||
| `/chat` | `sekunden` | 120 - danach verblasst eine Zeile (0 = nie) |
|
||||
| `/chat` | `befehle=1` | auch `!`-Befehle zeigen |
|
||||
| `/status` | `kanal` | abdulive_ |
|
||||
| `/effects` | `max` | 260 - Obergrenze für Teilchen |
|
||||
| `/starting` | `text`, `unter` | "Gleich geht es los", leer |
|
||||
|
||||
## Zum Ausprobieren
|
||||
|
||||
Auf der Übersichtsseite (`/`) steht unter **Ausprobieren** für jede Einblendung,
|
||||
jede Chat-Variante und jeden Effekt ein Knopf. Er löst ein erfundenes Ereignis
|
||||
aus, das nur an die Browser-Sources geht - nichts davon landet in der Datenbank,
|
||||
auf Discord oder bei Twitch.
|
||||
|
||||
Am besten die zu prüfende Seite über „ansehen" in einem zweiten Fenster offen
|
||||
haben. Dabei hilft `?probe=1`: das legt einen dunklen Grund unter die sonst
|
||||
durchsichtige Seite.
|
||||
|
||||
Zwei Dinge sind gesperrt, solange ein Stream läuft: „Stream läuft" und die
|
||||
Vorführung. Beide setzen einen Zustand, der in der Statusleiste stehen bliebe,
|
||||
bis die nächste Twitch-Abfrage ihn geraderückt. „Stand zurücksetzen" ist
|
||||
absichtlich nie gesperrt - das ist der Weg aus einem erfundenen Zustand wieder
|
||||
heraus.
|
||||
|
||||
Dieselben Ereignisse im Dauerbetrieb, auch ohne laufenden Dienst:
|
||||
|
||||
```
|
||||
abdulive overlay --demo
|
||||
```
|
||||
|
||||
Erfundener Chat, alle neun Sekunden eine Einblendung, ab und zu eine
|
||||
Chat-Spitze. Ohne das ließe sich eine Raid-Einblendung nur prüfen, indem man
|
||||
auf einen echten Raid wartet.
|
||||
|
||||
## Was fehlt
|
||||
|
||||
- **Follows.** Kommen nicht über IRC, dafür braucht es EventSub. Bis dahin
|
||||
bleibt das Overlay bei Follows stumm; Abos, Raids und Bits kommen an.
|
||||
- **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene
|
||||
Emotes werden angezeigt.
|
||||
- **Töne.** Es liegen keine Audiodateien im Repo, und ein Overlay, das
|
||||
ungefragt Krach macht, ist schlimmer als eines, das still ist.
|
||||
|
||||
## Vorlesen (`/tts`)
|
||||
|
||||
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||
|
||||
Gesprochen wird über `speechSynthesis`, die im Browser eingebaute
|
||||
Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das
|
||||
Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird
|
||||
teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet
|
||||
einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das
|
||||
auffallen und nicht stillschweigend fehlen.
|
||||
|
||||
Einstellungen: `?sprache=de-DE`, `?tempo=1`, `?laut=1`.
|
||||
|
||||
Vorgelesen wird nur, was auf der Übersichtsseite freigegeben wurde. Nach einem
|
||||
Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||
vorzulesen ist schlimmer, als sie zu verpassen.
|
||||
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Einblendungen</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Einblendungen laufen nacheinander, nie uebereinander. Zwei Alarme
|
||||
* gleichzeitig sind unlesbar, und bei einem Massengeschenk kommen zehn
|
||||
* Stueck in derselben Sekunde. */
|
||||
.buehne {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.karte {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 620px;
|
||||
max-width: 1100px;
|
||||
padding: 34px 54px 38px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0 34px;
|
||||
border-radius: 26px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, .14);
|
||||
box-shadow: var(--schatten), var(--glanz), 0 0 90px -20px var(--ton);
|
||||
/* Die Kante in der Akzentfarbe ist das, was die Einblendung dem Ereignis
|
||||
* zuordnet, bevor man ein Wort gelesen hat. */
|
||||
border-left: 8px solid var(--ton);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.karte.an { animation: herein .75s cubic-bezier(.16, 1.2, .3, 1) both; }
|
||||
.karte.weg { animation: hinaus .45s cubic-bezier(.5, 0, .75, 0) both; }
|
||||
|
||||
@keyframes herein {
|
||||
from { opacity: 0; transform: translateY(46px) scale(.9); filter: blur(8px); }
|
||||
to { opacity: 1; transform: none; filter: none; }
|
||||
}
|
||||
@keyframes hinaus {
|
||||
from { opacity: 1; transform: none; }
|
||||
to { opacity: 0; transform: translateY(-34px) scale(.96); }
|
||||
}
|
||||
|
||||
.zeichen {
|
||||
font-size: 92px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .6));
|
||||
animation: huepfen .9s cubic-bezier(.2, 1.6, .4, 1) .12s both;
|
||||
}
|
||||
@keyframes huepfen {
|
||||
0% { transform: scale(0) rotate(-25deg); }
|
||||
70% { transform: scale(1.18) rotate(6deg); }
|
||||
100% { transform: scale(1) rotate(0); }
|
||||
}
|
||||
|
||||
.kopf {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ton);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.name {
|
||||
font-size: 58px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.zusatz { font-size: 26px; color: var(--schrift-leise); margin-top: 6px; }
|
||||
.notiz {
|
||||
margin-top: 14px;
|
||||
font-size: 22px;
|
||||
color: var(--schrift-leise);
|
||||
max-width: 44ch;
|
||||
/* Zuschauertext. Drei Zeilen, dann Schluss - sonst schiebt jemand mit
|
||||
* einem Roman die Karte aus dem Bild. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notiz .emote { height: 1.4em; vertical-align: -.3em; }
|
||||
|
||||
/* Der Balken, der unter der Karte ablaeuft: zeigt, wie lange sie noch steht. */
|
||||
.uhr {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 4px;
|
||||
background: var(--ton);
|
||||
transform-origin: left;
|
||||
animation: ablaufen var(--dauer) linear both;
|
||||
opacity: .8;
|
||||
}
|
||||
@keyframes ablaufen { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="buehne"><div id="platz"></div></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var platz = document.getElementById('platz');
|
||||
var dauer = Overlay.param('sekunden', 7) * 1000;
|
||||
var warteschlange = [];
|
||||
var laeuft = false;
|
||||
|
||||
/* Ein Eintrag je Art: Farbe, Zeichen, Ueberschrift und die beiden
|
||||
* Textzeilen. Alles an einem Ort, damit eine neue Art eine Zeile ist. */
|
||||
var ARTEN = {
|
||||
sub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Neues Abo' },
|
||||
resub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Abo verlängert' },
|
||||
subgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abo verschenkt' },
|
||||
massgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abos verschenkt' },
|
||||
raid: { ton: 'var(--tuerkis)', zeichen: '🚀', kopf: 'Raid' },
|
||||
cheer: { ton: 'var(--gold)', zeichen: '✨', kopf: 'Bits' },
|
||||
follow: { ton: 'var(--gruen)', zeichen: '⭐', kopf: 'Neuer Follow' },
|
||||
first: { ton: 'var(--gruen)', zeichen: '👋', kopf: 'Zum ersten Mal hier' },
|
||||
clip: { ton: 'var(--tuerkis)', zeichen: '🎬', kopf: 'Clip gespeichert' },
|
||||
// Eigene Art, eigene Farbe, eigene Ueberschrift. Eine eingeloeste
|
||||
// Belohnung darf nicht aussehen wie ein Abo.
|
||||
reward: { ton: 'var(--gold)', zeichen: '🎟️', kopf: 'Belohnung eingelöst' },
|
||||
};
|
||||
|
||||
/** Die zweite Zeile. Deutsch mit richtigen Pluralformen - "1 Monate"
|
||||
* liest sich wie ein Fehler, und das ist es auch. */
|
||||
function zusatz(ev) {
|
||||
switch (ev.type) {
|
||||
case 'resub':
|
||||
return ev.months ? ev.months + (ev.months === 1 ? ' Monat' : ' Monate') + ' dabei' : '';
|
||||
case 'subgift':
|
||||
return ev.recipient ? 'an ' + Overlay.esc(ev.recipient) : '';
|
||||
case 'massgift':
|
||||
return (ev.amount || 1) + (ev.amount === 1 ? ' Abo' : ' Abos') + ' an den Chat';
|
||||
case 'raid':
|
||||
return 'mit ' + Overlay.zahl(ev.amount || 0) +
|
||||
((ev.amount === 1) ? ' Zuschauer' : ' Zuschauern');
|
||||
case 'cheer':
|
||||
return Overlay.zahl(ev.amount || 0) + ' Bits';
|
||||
case 'reward':
|
||||
// Der Titel der Belohnung ist die Information, nicht die Punktzahl.
|
||||
return Overlay.esc(ev.reward || '') +
|
||||
(ev.amount ? ' · ' + Overlay.zahl(ev.amount) + ' Punkte' : '');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function zeige(ev) {
|
||||
var art = ARTEN[ev.type];
|
||||
if (!art) return naechste();
|
||||
|
||||
var karte = document.createElement('div');
|
||||
karte.className = 'karte an';
|
||||
karte.style.setProperty('--ton', art.ton);
|
||||
karte.style.setProperty('--dauer', (dauer / 1000) + 's');
|
||||
|
||||
var text = zusatz(ev);
|
||||
// Die Nachricht nur dort, wo Zuschauer wirklich eine schreiben.
|
||||
var notiz = (ev.message && (ev.type === 'resub' || ev.type === 'cheer' ||
|
||||
ev.type === 'sub' || ev.type === 'reward'))
|
||||
? '<div class="notiz">' + Overlay.emoteHtml(ev.message, []) + '</div>' : '';
|
||||
|
||||
karte.innerHTML =
|
||||
'<div class="zeichen">' + art.zeichen + '</div>' +
|
||||
'<div>' +
|
||||
'<div class="kopf">' + art.kopf + '</div>' +
|
||||
'<div class="name kontur">' + Overlay.esc(ev.name || 'Jemand') + '</div>' +
|
||||
(text ? '<div class="zusatz">' + text + '</div>' : '') +
|
||||
notiz +
|
||||
'</div>' +
|
||||
'<div class="uhr"></div>';
|
||||
|
||||
// Der Lichtstreifen liegt auf der Karte, nicht im Markup - so laeuft er
|
||||
// genau einmal und stoert das Raster nicht.
|
||||
karte.classList.add('streifen');
|
||||
platz.appendChild(karte);
|
||||
|
||||
setTimeout(function () {
|
||||
karte.classList.remove('an');
|
||||
karte.classList.add('weg');
|
||||
setTimeout(function () { karte.remove(); naechste(); }, 460);
|
||||
}, dauer);
|
||||
}
|
||||
|
||||
function naechste() {
|
||||
var ev = warteschlange.shift();
|
||||
if (!ev) { laeuft = false; return; }
|
||||
laeuft = true;
|
||||
zeige(ev);
|
||||
}
|
||||
|
||||
Overlay.on('alert', function (ev) {
|
||||
// Erstchatter sind haeufig und sollen den Bildschirm nicht sprengen -
|
||||
// wer sie nicht will, haengt ?erstchatter=0 an die Adresse.
|
||||
if (ev.type === 'first' && !Overlay.param('erstchatter', true)) return;
|
||||
warteschlange.push(ev);
|
||||
// Eine Warteschlange, die bei einem Bot-Angriff auf 200 waechst, laeuft
|
||||
// eine halbe Stunde lang durch. Lieber die aeltesten fallen lassen.
|
||||
if (warteschlange.length > 12) warteschlange.splice(0, warteschlange.length - 12);
|
||||
if (!laeuft) naechste();
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden NICHT die alten Alarme nachspielen: ein Abo von
|
||||
// vor zehn Minuten noch einmal einzublenden ist schlimmer als es zu
|
||||
// verpassen. Der Schnappschuss wird hier bewusst ignoriert.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Chat</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Von unten nach oben, wie im Chat selbst: die neueste Nachricht steht
|
||||
* unten, wo das Auge sie erwartet. Deshalb column-reverse und nicht ein
|
||||
* Scrollcontainer - der haette einen Balken und wuerde mitwandern. */
|
||||
#liste {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zeile {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(18, 14, 29, .66);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .45);
|
||||
font-size: 19px;
|
||||
line-height: 1.38;
|
||||
word-break: break-word;
|
||||
animation: herein .42s cubic-bezier(.16, 1.1, .3, 1) both;
|
||||
transition: opacity .6s, transform .6s;
|
||||
}
|
||||
@keyframes herein {
|
||||
from { opacity: 0; transform: translateX(-26px) scale(.97); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.zeile.geht { opacity: 0; transform: translateX(-20px); }
|
||||
|
||||
.name { font-weight: 700; margin-right: .45em; }
|
||||
.text .emote { height: 1.55em; vertical-align: -.4em; margin: 0 .05em; }
|
||||
|
||||
/* Ein GIF ist kein Emote: es steht fuer sich, in eigener Zeile, und wird
|
||||
* begrenzt. Ohne Deckel schiebt ein 500 Pixel breites GIF die ganze Spalte
|
||||
* auf - in einer 460 Pixel schmalen Browser-Source faellt das sofort auf. */
|
||||
.text .gif {
|
||||
display: block;
|
||||
margin: 6px 0 2px;
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
width: auto;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, .35);
|
||||
}
|
||||
|
||||
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten
|
||||
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */
|
||||
.abzeichen {
|
||||
display: inline-block;
|
||||
width: .5em; height: .5em;
|
||||
border-radius: 50%;
|
||||
margin-right: .4em;
|
||||
vertical-align: .08em;
|
||||
}
|
||||
.abzeichen.mod { background: var(--gruen); }
|
||||
.abzeichen.abo { background: var(--lila); }
|
||||
.abzeichen.host { background: var(--rot); }
|
||||
|
||||
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
|
||||
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
|
||||
* tragen das jetzt der Farbverlauf und die Zeile darueber allein, deshalb ist
|
||||
* der Verlauf kraeftiger als vorher. */
|
||||
.zeile.erstmals {
|
||||
background: linear-gradient(90deg, rgba(74, 222, 128, .24), rgba(18, 14, 29, .66) 65%);
|
||||
}
|
||||
.zeile.erstmals::before {
|
||||
content: "zum ersten Mal hier";
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gruen);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.zeile.bits { background: linear-gradient(90deg, rgba(255, 197, 61, .22), rgba(18, 14, 29, .66) 65%); }
|
||||
.zeile.bits .name { color: var(--gold) !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="liste"></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var liste = document.getElementById('liste');
|
||||
var maximal = Overlay.param('max', 14);
|
||||
var lebensdauer = Overlay.param('sekunden', 120) * 1000;
|
||||
var befehleZeigen = Overlay.param('befehle', false);
|
||||
|
||||
/* Twitchs Farben werden auf dunklem Grund teils unlesbar - dunkelblau auf
|
||||
* Schwarz ist nicht zu entziffern. Deshalb wird die Helligkeit angehoben
|
||||
* statt die Farbe zu verwerfen: die Farbe ist Wiedererkennung. */
|
||||
function lesbar(hex) {
|
||||
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return '#c9a7ff';
|
||||
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16),
|
||||
b = parseInt(hex.slice(5, 7), 16);
|
||||
var hell = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
if (hell >= 0.5) return hex;
|
||||
var f = 0.5 / Math.max(hell, 0.08);
|
||||
var an = function (v) { return Math.min(255, Math.round(v * f)); };
|
||||
return '#' + [an(r), an(g), an(b)].map(function (v) {
|
||||
return v.toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function entferne(el) {
|
||||
// Doppelt aufrufbar: das Zeitlimit und das Nachruecken koennen dieselbe
|
||||
// Zeile treffen.
|
||||
if (!el || !el.parentNode || el.classList.contains('geht')) return;
|
||||
el.classList.add('geht');
|
||||
setTimeout(function () { el.remove(); }, 620);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeilen, die ueber die Obergrenze hinausgehen, ausblenden.
|
||||
*
|
||||
* Gezaehlt wird nur, was noch nicht auf dem Weg nach draussen ist: eine
|
||||
* ausblendende Zeile haengt noch eine halbe Sekunde im DOM. Wer stattdessen
|
||||
* `liste.children.length` zaehlt und in einer while-Schleife entfernt,
|
||||
* bekommt eine Endlosschleife - die Laenge sinkt ja erst nach der
|
||||
* Animation. In OBS friert die Quelle damit beim vierzehnten Chatbeitrag
|
||||
* ein, und im Browser sieht man einen haengenden Tab.
|
||||
*/
|
||||
function aufraeumen() {
|
||||
var bleibend = [];
|
||||
var zeilen = liste.children;
|
||||
for (var i = 0; i < zeilen.length; i++) {
|
||||
if (!zeilen[i].classList.contains('geht')) bleibend.push(zeilen[i]);
|
||||
}
|
||||
// index 0 ist die neueste Zeile, also faellt das Ende zuerst.
|
||||
for (var j = maximal; j < bleibend.length; j++) entferne(bleibend[j]);
|
||||
}
|
||||
|
||||
function zeige(ev) {
|
||||
if (!befehleZeigen && ev.text.charAt(0) === '!') return;
|
||||
|
||||
var el = document.createElement('div');
|
||||
el.className = 'zeile' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : '');
|
||||
el.dataset.id = ev.id;
|
||||
el.dataset.user = ev.userId;
|
||||
|
||||
var abzeichen = '';
|
||||
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>';
|
||||
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
|
||||
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
|
||||
|
||||
el.innerHTML =
|
||||
abzeichen +
|
||||
'<span class="name" style="color:' + lesbar(ev.color) + '">' +
|
||||
Overlay.esc(ev.name) + '</span>' +
|
||||
'<span class="text">' + Overlay.nachrichtHtml(ev.text, ev.gifs, ev.emotes) + '</span>';
|
||||
|
||||
// prepend, weil column-reverse die Liste ohnehin umdreht.
|
||||
liste.insertBefore(el, liste.firstChild);
|
||||
aufraeumen();
|
||||
|
||||
if (lebensdauer > 0) setTimeout(function () { entferne(el); }, lebensdauer);
|
||||
}
|
||||
|
||||
Overlay.on('chat', zeige);
|
||||
|
||||
// Loeschen durch einen Mod muss durchschlagen: eine geloeschte Beleidigung,
|
||||
// die noch zwei Minuten im Bild steht, steht auch in der Aufzeichnung.
|
||||
Overlay.on('clear', function (ev) {
|
||||
var alle = Array.prototype.slice.call(liste.children);
|
||||
for (var i = 0; i < alle.length; i++) {
|
||||
var el = alle[i];
|
||||
if (!ev.messageId && !ev.userId) entferne(el);
|
||||
else if (ev.messageId && el.dataset.id === ev.messageId) entferne(el);
|
||||
else if (ev.userId && el.dataset.user === ev.userId) entferne(el);
|
||||
}
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden den letzten Stand zeigen, damit die Quelle nicht
|
||||
// leer im Bild steht, bis jemand schreibt.
|
||||
Overlay.on('state', function (ev) {
|
||||
liste.innerHTML = '';
|
||||
var letzte = ev.chat.slice(-maximal);
|
||||
for (var i = 0; i < letzte.length; i++) zeige(letzte[i]);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Gemeinsamer Anschluss aller Browser-Sources an den Ereignisstrom.
|
||||
*
|
||||
* Bewusst kein Modul und kein Build: OBS laedt diese Datei direkt, und jede
|
||||
* Zwischenstufe waere ein Ding, das im Stream kaputtgehen kann.
|
||||
*
|
||||
* Die drei Dinge, an denen solche Overlays sonst scheitern:
|
||||
*
|
||||
* 1. Die Quelle bleibt nach einem Neustart des Dienstes tot. -> EventSource
|
||||
* verbindet von allein neu, und ein Wachhund greift ein, wenn selbst das
|
||||
* haengt (OBS haelt tote Verbindungen erstaunlich lange fuer lebendig).
|
||||
* 2. Die Quelle ist nach dem Neuverbinden leer. -> Der Server schickt als
|
||||
* erstes einen Schnappschuss, den die Seiten als `state` bekommen.
|
||||
* 3. Chat-Text landet ungeprueft im DOM. -> `Overlay.esc` und `emoteHtml`
|
||||
* sind die einzigen Wege, auf denen fremder Text hier hineinkommt.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var params = new URLSearchParams(location.search);
|
||||
var handlers = {};
|
||||
var quelle = null;
|
||||
var letzteNachricht = Date.now();
|
||||
|
||||
/** Ohne Nachricht in dieser Zeit gilt die Verbindung als tot. Der Server
|
||||
* schickt alle 15 s einen Herzschlag, also sind 45 s drei verpasste. */
|
||||
var TOT_NACH_MS = 45000;
|
||||
|
||||
function verbinde() {
|
||||
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
|
||||
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : '');
|
||||
quelle = new EventSource(url);
|
||||
letzteNachricht = Date.now();
|
||||
|
||||
quelle.onopen = function () {
|
||||
document.body.classList.remove('offline');
|
||||
letzteNachricht = Date.now();
|
||||
};
|
||||
quelle.onerror = function () {
|
||||
document.body.classList.add('offline');
|
||||
};
|
||||
quelle.onmessage = function (e) {
|
||||
letzteNachricht = Date.now();
|
||||
document.body.classList.remove('offline');
|
||||
var ev;
|
||||
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
||||
if (ev.kind === 'beat') return;
|
||||
var liste = handlers[ev.kind] || [];
|
||||
for (var i = 0; i < liste.length; i++) {
|
||||
try { liste[i](ev); } catch (err) { console.error('[overlay]', ev.kind, err); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
if (Date.now() - letzteNachricht > TOT_NACH_MS) {
|
||||
console.warn('[overlay] nichts mehr gehoert, verbinde neu');
|
||||
verbinde();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
window.Overlay = {
|
||||
/** Auf eine Ereignisart hoeren. Mehrere Hoerer je Art sind erlaubt. */
|
||||
on: function (kind, fn) {
|
||||
(handlers[kind] = handlers[kind] || []).push(fn);
|
||||
return window.Overlay;
|
||||
},
|
||||
|
||||
/** Einstellung aus der Adresse, z. B. ?sekunden=60. */
|
||||
param: function (name, fallback) {
|
||||
var v = params.get(name);
|
||||
if (v === null || v === '') return fallback;
|
||||
if (typeof fallback === 'number') {
|
||||
var n = Number(v);
|
||||
return isFinite(n) ? n : fallback;
|
||||
}
|
||||
if (typeof fallback === 'boolean') return v !== '0' && v !== 'false' && v !== 'nein';
|
||||
return v;
|
||||
},
|
||||
|
||||
/** Der einzige erlaubte Weg, fremden Text ins DOM zu bringen. */
|
||||
esc: function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Text mit eingesetzten Emotes. Erst maskieren, dann ersetzen - in der
|
||||
* anderen Reihenfolge waere jeder Emote-Name ein Einfallstor.
|
||||
*
|
||||
* Twitch liefert nur Name und ID, nicht die Position. Ersetzt wird
|
||||
* deshalb der Name als ganzes Wort. BTTV und 7TV sind nicht dabei; die
|
||||
* haengen an einer zweiten API und waeren ein eigenes Stueck Arbeit.
|
||||
*/
|
||||
emoteHtml: function (text, emotes) {
|
||||
var html = window.Overlay.esc(text);
|
||||
if (!emotes || !emotes.length) return html;
|
||||
for (var i = 0; i < emotes.length; i++) {
|
||||
var e = emotes[i];
|
||||
if (!e.id || !e.name) continue;
|
||||
var sicher = window.Overlay.esc(e.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
var src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||
encodeURIComponent(e.id) + '/default/dark/2.0';
|
||||
html = html.replace(new RegExp('(^|\\s)' + sicher + '(?=\\s|$)', 'g'),
|
||||
'$1<img class="emote" src="' + src + '" alt="' + window.Overlay.esc(e.name) + '">');
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Nachrichtentext mit eingesetzten GIFs und Emotes.
|
||||
*
|
||||
* GIFs kommen anders als Emotes mit einem Zeichenbereich: im Text steht
|
||||
* nur ein Platzhalter wie "[Idk GIF by Respective]", und der gehoert
|
||||
* ersetzt. Wer nur den Text anzeigt, zeigt genau diese eckige Klammer.
|
||||
*/
|
||||
nachrichtHtml: function (text, gifs, emotes) {
|
||||
if (!gifs || !gifs.length) return window.Overlay.emoteHtml(text, emotes);
|
||||
|
||||
// Nach Zeichen zerlegen, nicht nach Bytes: die Bereiche zaehlen
|
||||
// Zeichen, und ein Emoji im Text wuerde die Rechnung sonst verschieben.
|
||||
var chars = Array.from(text);
|
||||
var sortiert = gifs.slice().sort(function (a, b) { return a.from - b.from; });
|
||||
var html = '';
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < sortiert.length; i++) {
|
||||
var g = sortiert[i];
|
||||
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') continue;
|
||||
if (g.from > pos) {
|
||||
html += window.Overlay.emoteHtml(chars.slice(pos, g.from).join(''), emotes);
|
||||
}
|
||||
html += '<img class="gif" src="' + window.Overlay.esc(g.url) + '" alt="GIF"' +
|
||||
' onerror="this.replaceWith(document.createTextNode(this.alt))">';
|
||||
pos = Math.max(pos, g.to + 1);
|
||||
}
|
||||
if (pos < chars.length) {
|
||||
html += window.Overlay.emoteHtml(chars.slice(pos).join(''), emotes);
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/** "2 Std 14 Min" aus einem ISO-Zeitpunkt. */
|
||||
laufzeit: function (seit) {
|
||||
var ms = Date.now() - Date.parse(seit);
|
||||
if (!isFinite(ms) || ms < 0) return '';
|
||||
var m = Math.floor(ms / 60000);
|
||||
var h = Math.floor(m / 60);
|
||||
return h > 0 ? h + ':' + String(m % 60).padStart(2, '0') + ' Std'
|
||||
: m + ' Min';
|
||||
},
|
||||
|
||||
/** Zahl mit Tausenderpunkt, wie man sie in Deutschland liest. */
|
||||
zahl: function (n) {
|
||||
return Number(n || 0).toLocaleString('de-DE');
|
||||
},
|
||||
};
|
||||
|
||||
// Der Punkt unten rechts, der nur bei Verlust erscheint.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (params.get('probe')) document.body.classList.add('probe');
|
||||
var d = document.createElement('div');
|
||||
d.className = 'getrennt';
|
||||
d.textContent = 'keine Verbindung';
|
||||
document.body.appendChild(d);
|
||||
verbinde();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Effekte</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Ein Canvas fuer alles. Hunderte DOM-Knoten, die sich pro Bild bewegen,
|
||||
* kosten in OBS spuerbar Leistung - und Leistung, die hier draufgeht, fehlt
|
||||
* beim Encoder. */
|
||||
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* Der kurze Lichtschein bei einer Chat-Spitze. Liegt hinter den Teilchen und
|
||||
* ist das, was den Effekt "teuer" wirken laesst. */
|
||||
#schein {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(ellipse at 50% 60%,
|
||||
rgba(169, 112, 255, .32), transparent 62%);
|
||||
}
|
||||
#schein.an { animation: blitz 1.6s ease-out both; }
|
||||
@keyframes blitz {
|
||||
0% { opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="schein"></div>
|
||||
<canvas id="bild"></canvas>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var canvas = document.getElementById('bild');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var schein = document.getElementById('schein');
|
||||
var teilchen = [];
|
||||
var bilder = {}; // Emote-ID -> Image
|
||||
var letzteEmotes = []; // was gerade im Chat benutzt wird
|
||||
var laeuft = false;
|
||||
|
||||
/** Obergrenze. Ein Raid mit 200 Leuten darf den Encoder nicht umbringen. */
|
||||
var MAX = Overlay.param('max', 260);
|
||||
|
||||
function passeAn() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
passeAn();
|
||||
window.addEventListener('resize', passeAn);
|
||||
|
||||
function bild(id) {
|
||||
if (bilder[id]) return bilder[id];
|
||||
var img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||
encodeURIComponent(id) + '/default/dark/3.0';
|
||||
bilder[id] = img;
|
||||
return img;
|
||||
}
|
||||
|
||||
/** Emote-Regen: faellt von oben, dreht sich leicht, verblasst unten. */
|
||||
function regen(anzahl) {
|
||||
var quellen = letzteEmotes.slice(-12);
|
||||
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||
var e = quellen.length ? quellen[(Math.random() * quellen.length) | 0] : null;
|
||||
teilchen.push({
|
||||
art: e ? 'emote' : 'punkt',
|
||||
img: e ? bild(e) : null,
|
||||
farbe: farbe(),
|
||||
x: Math.random() * canvas.width,
|
||||
y: -60 - Math.random() * canvas.height * 0.5,
|
||||
vx: (Math.random() - 0.5) * 0.8,
|
||||
vy: 2.2 + Math.random() * 3.4,
|
||||
g: 0.02,
|
||||
dreh: (Math.random() - 0.5) * 0.06,
|
||||
winkel: (Math.random() - 0.5) * 0.7,
|
||||
groesse: 34 + Math.random() * 30,
|
||||
leben: 1,
|
||||
zerfall: 0.0016 + Math.random() * 0.0016,
|
||||
});
|
||||
}
|
||||
starte();
|
||||
}
|
||||
|
||||
var PALETTE = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d', '#4ade80'];
|
||||
function farbe() { return PALETTE[(Math.random() * PALETTE.length) | 0]; }
|
||||
|
||||
/** Konfetti: schiesst aus der unteren Mitte nach oben und faellt zurueck. */
|
||||
function konfetti(anzahl) {
|
||||
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||
var winkel = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
|
||||
var tempo = 9 + Math.random() * 11;
|
||||
teilchen.push({
|
||||
art: 'schnipsel',
|
||||
farbe: farbe(),
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 340,
|
||||
y: canvas.height + 14,
|
||||
vx: Math.cos(winkel) * tempo,
|
||||
vy: Math.sin(winkel) * tempo,
|
||||
g: 0.28,
|
||||
dreh: (Math.random() - 0.5) * 0.4,
|
||||
winkel: Math.random() * Math.PI,
|
||||
groesse: 9 + Math.random() * 9,
|
||||
leben: 1,
|
||||
zerfall: 0.0055 + Math.random() * 0.004,
|
||||
});
|
||||
}
|
||||
starte();
|
||||
}
|
||||
|
||||
function starte() {
|
||||
if (laeuft) return;
|
||||
laeuft = true;
|
||||
requestAnimationFrame(schritt);
|
||||
}
|
||||
|
||||
function schritt() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (var i = teilchen.length - 1; i >= 0; i--) {
|
||||
var t = teilchen[i];
|
||||
t.vy += t.g;
|
||||
t.x += t.vx;
|
||||
t.y += t.vy;
|
||||
t.winkel += t.dreh;
|
||||
t.leben -= t.zerfall;
|
||||
|
||||
if (t.leben <= 0 || t.y > canvas.height + 120) { teilchen.splice(i, 1); continue; }
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, t.leben));
|
||||
ctx.translate(t.x, t.y);
|
||||
ctx.rotate(t.winkel);
|
||||
|
||||
if (t.art === 'emote' && t.img && t.img.complete && t.img.naturalWidth) {
|
||||
ctx.drawImage(t.img, -t.groesse / 2, -t.groesse / 2, t.groesse, t.groesse);
|
||||
} else if (t.art === 'schnipsel') {
|
||||
ctx.fillStyle = t.farbe;
|
||||
// Schmales Rechteck statt Quadrat: sieht in Drehung nach Papier aus.
|
||||
ctx.fillRect(-t.groesse / 2, -t.groesse / 4, t.groesse, t.groesse / 2);
|
||||
} else {
|
||||
ctx.fillStyle = t.farbe;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, t.groesse / 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (teilchen.length) requestAnimationFrame(schritt);
|
||||
else { laeuft = false; ctx.clearRect(0, 0, canvas.width, canvas.height); }
|
||||
}
|
||||
|
||||
// ---- Ausloeser
|
||||
|
||||
Overlay.on('chat', function (ev) {
|
||||
for (var i = 0; i < ev.emotes.length; i++) {
|
||||
if (letzteEmotes.indexOf(ev.emotes[i].id) === -1) letzteEmotes.push(ev.emotes[i].id);
|
||||
}
|
||||
if (letzteEmotes.length > 24) letzteEmotes.splice(0, letzteEmotes.length - 24);
|
||||
});
|
||||
|
||||
Overlay.on('hype', function (ev) {
|
||||
schein.classList.remove('an');
|
||||
void schein.offsetWidth;
|
||||
schein.classList.add('an');
|
||||
// Je hoeher die Punktzahl, desto mehr - aber gedeckelt, sonst steht bei
|
||||
// einem echten Ausbruch das Bild voll und man sieht das Spiel nicht mehr.
|
||||
regen(Math.min(110, 34 + Math.round((ev.score || 1) * 9)));
|
||||
});
|
||||
|
||||
// Konfetti als eigenes Ereignis: welche Belohnung Konfetti ausloest,
|
||||
// entscheidet der Hub - diese Seite muss davon nichts wissen.
|
||||
Overlay.on('konfetti', function (ev) {
|
||||
konfetti(Math.round(90 * (ev.staerke || 1)));
|
||||
});
|
||||
|
||||
Overlay.on('alert', function (ev) {
|
||||
if (ev.type === 'raid') konfetti(Math.min(180, 70 + (ev.amount || 0) * 2));
|
||||
else if (ev.type === 'massgift') konfetti(Math.min(180, 60 + (ev.amount || 0) * 10));
|
||||
else if (ev.type === 'sub' || ev.type === 'resub' || ev.type === 'subgift') konfetti(80);
|
||||
else if (ev.type === 'cheer' && (ev.amount || 0) >= 100) konfetti(60);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Pausenbild</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Das einzige Overlay mit deckendem Hintergrund: es ersetzt das Bild, statt
|
||||
* darueber zu liegen. Gedacht als eigene OBS-Szene fuer "gleich geht es los",
|
||||
* Pause und Ende. Text ueber ?text= und ?unter=. */
|
||||
body { background: #0a0813; }
|
||||
|
||||
#grund {
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
radial-gradient(42% 42% at 22% 28%, rgba(169, 112, 255, .55), transparent 70%),
|
||||
radial-gradient(38% 38% at 78% 32%, rgba(79, 209, 197, .45), transparent 70%),
|
||||
radial-gradient(45% 45% at 55% 82%, rgba(255, 79, 163, .40), transparent 70%);
|
||||
filter: blur(48px);
|
||||
animation: treiben 28s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes treiben {
|
||||
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
50% { transform: translate(-3%, 2.5%) scale(1.1) rotate(4deg); }
|
||||
100% { transform: translate(3%, -2%) scale(1.04) rotate(-3deg); }
|
||||
}
|
||||
|
||||
/* Feines Raster darueber: nimmt dem Verlauf das Billige und gibt dem Bild
|
||||
* eine Struktur, die auch nach der Twitch-Kompression noch da ist. */
|
||||
#raster {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
mask-image: radial-gradient(70% 70% at 50% 50%, #000, transparent);
|
||||
}
|
||||
|
||||
#mitte {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(64px, 8.5vw, 148px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -.035em;
|
||||
/* Unterlaengen brauchen Platz. `background-clip: text` faerbt nur, was
|
||||
* innerhalb der Polsterbox liegt - was darueber hinausragt, bleibt
|
||||
* durchsichtig und sieht abgeschnitten aus. Bei fetter Schrift in dieser
|
||||
* Groesse reicht eine knappe Zeilenhoehe dafuer nicht.
|
||||
*
|
||||
* Nachgemessen am "g" in "Gleich geht es los": mit 1.04 und .06em fehlte
|
||||
* noch ein Stueck vom Bogen. */
|
||||
line-height: 1.18;
|
||||
padding-bottom: .14em;
|
||||
background: linear-gradient(100deg, #fff 18%, var(--lila) 48%, var(--tuerkis) 78%, #fff 96%);
|
||||
background-size: 260% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: schimmern 7s linear infinite;
|
||||
filter: drop-shadow(0 10px 40px rgba(0, 0, 0, .55));
|
||||
}
|
||||
@keyframes schimmern { to { background-position: 260% 0; } }
|
||||
|
||||
#unter {
|
||||
font-size: clamp(20px, 2vw, 30px);
|
||||
color: var(--schrift-leise);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/* Der Taktgeber: fuenf Balken, die atmen. Ohne bewegtes Element wirkt ein
|
||||
* Standbild wie ein eingefrorener Stream, und Zuschauer laden neu. */
|
||||
#takt { display: flex; gap: 12px; align-items: flex-end; height: 68px; margin-top: 10px; }
|
||||
#takt i {
|
||||
display: block;
|
||||
width: 13px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(180deg, var(--tuerkis), var(--lila));
|
||||
animation: atmen 1.5s ease-in-out infinite;
|
||||
}
|
||||
#takt i:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||
#takt i:nth-child(2) { height: 75%; animation-delay: .18s; }
|
||||
#takt i:nth-child(3) { height: 100%; animation-delay: .36s; }
|
||||
#takt i:nth-child(4) { height: 65%; animation-delay: .54s; }
|
||||
#takt i:nth-child(5) { height: 35%; animation-delay: .72s; }
|
||||
@keyframes atmen {
|
||||
0%, 100% { transform: scaleY(.35); opacity: .6; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
|
||||
#fuss {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 42px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 44px;
|
||||
font-size: 22px;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
#fuss b { color: var(--schrift); font-weight: 600; }
|
||||
#uhr { font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="grund"></div>
|
||||
<div id="raster"></div>
|
||||
<div id="mitte">
|
||||
<h1 id="gross">Gleich geht es los</h1>
|
||||
<div id="unter"></div>
|
||||
<div id="takt"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<div id="fuss">
|
||||
<span id="uhr"></span>
|
||||
<span>twitch.tv/<b id="kanal">abdulive_</b></span>
|
||||
<span id="thema"></span>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.getElementById('gross').textContent = Overlay.param('text', 'Gleich geht es los');
|
||||
document.getElementById('unter').textContent = Overlay.param('unter', '');
|
||||
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||
|
||||
var uhr = document.getElementById('uhr');
|
||||
function zeit() {
|
||||
uhr.textContent = new Date().toLocaleTimeString('de-DE',
|
||||
{ hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
zeit();
|
||||
setInterval(zeit, 10000);
|
||||
|
||||
// Laeuft der Stream schon, steht unten, worum es geht. Das ist der
|
||||
// Unterschied zwischen einem Pausenbild und einem Standbild.
|
||||
var thema = document.getElementById('thema');
|
||||
function stand(s) {
|
||||
thema.textContent = s.live && s.category ? 'heute: ' + s.category : '';
|
||||
}
|
||||
Overlay.on('stream', stand);
|
||||
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Statusleiste</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
#leiste {
|
||||
position: fixed;
|
||||
left: 28px; right: 28px; top: 22px;
|
||||
height: 92px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 30px;
|
||||
border-radius: 20px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
/* Ein Farbverlauf an der Oberkante statt eines Rahmens: er bindet die
|
||||
* Leiste an die Marke, ohne einen Kasten um alles zu ziehen. */
|
||||
overflow: hidden;
|
||||
}
|
||||
#leiste::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
|
||||
background-size: 300% 100%;
|
||||
animation: wandern 9s linear infinite;
|
||||
}
|
||||
@keyframes wandern { to { background-position: 300% 0; } }
|
||||
|
||||
.marke { display: flex; align-items: center; gap: 12px; }
|
||||
.punkt {
|
||||
width: 13px; height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--rot);
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, .6);
|
||||
}
|
||||
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
|
||||
body:not(.live) .punkt { background: #555; }
|
||||
@keyframes klopfen {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
|
||||
70% { box-shadow: 0 0 0 16px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
.kanal { font-size: 26px; font-weight: 700; letter-spacing: -.01em; }
|
||||
|
||||
.mitte { min-width: 0; }
|
||||
#titel {
|
||||
font-size: 25px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
#kategorie { font-size: 17px; color: var(--schrift-leise); margin-top: 2px; }
|
||||
|
||||
.werte { display: flex; gap: 30px; }
|
||||
.wert { text-align: right; min-width: 92px; }
|
||||
.wert .zahl {
|
||||
font-size: 30px; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
display: block;
|
||||
}
|
||||
.wert .beschriftung {
|
||||
font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
/* Kurzes Aufleuchten, wenn sich eine Zahl aendert. Ohne das uebersieht man
|
||||
* jede Veraenderung, weil die Leiste sonst voellig still steht. */
|
||||
.zahl.neu { animation: aufleuchten .8s ease-out; }
|
||||
@keyframes aufleuchten {
|
||||
0% { color: var(--tuerkis); transform: translateY(-3px); }
|
||||
100% { color: inherit; transform: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="leiste">
|
||||
<div class="marke">
|
||||
<span class="punkt"></span>
|
||||
<span class="kanal kontur" id="kanal">abdulive_</span>
|
||||
</div>
|
||||
<div class="mitte">
|
||||
<div id="titel" class="kontur">nicht live</div>
|
||||
<div id="kategorie"></div>
|
||||
</div>
|
||||
<div class="werte">
|
||||
<div class="wert"><span class="zahl" id="zuschauer">-</span>
|
||||
<span class="beschriftung">Zuschauer</span></div>
|
||||
<div class="wert"><span class="zahl" id="laufzeit">-</span>
|
||||
<span class="beschriftung">live seit</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var titel = document.getElementById('titel');
|
||||
var kategorie = document.getElementById('kategorie');
|
||||
var zuschauer = document.getElementById('zuschauer');
|
||||
var laufzeit = document.getElementById('laufzeit');
|
||||
var kanal = document.getElementById('kanal');
|
||||
var seit = null;
|
||||
|
||||
kanal.textContent = Overlay.param('kanal', 'abdulive_');
|
||||
|
||||
function setze(el, wert) {
|
||||
if (el.textContent === wert) return;
|
||||
el.textContent = wert;
|
||||
el.classList.remove('neu');
|
||||
// Neuzeichnen erzwingen, sonst startet die Animation beim zweiten Mal nicht.
|
||||
void el.offsetWidth;
|
||||
el.classList.add('neu');
|
||||
}
|
||||
|
||||
function stand(ev) {
|
||||
document.body.classList.toggle('live', !!ev.live);
|
||||
titel.textContent = ev.live ? (ev.title || 'ohne Titel') : 'nicht live';
|
||||
kategorie.textContent = ev.live ? (ev.category || '') : '';
|
||||
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
|
||||
seit = ev.live ? ev.startedAt : null;
|
||||
tick();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
laufzeit.textContent = seit ? Overlay.laufzeit(seit) : '-';
|
||||
}
|
||||
setInterval(tick, 10000);
|
||||
|
||||
Overlay.on('stream', stand);
|
||||
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Gemeinsames Aussehen aller Browser-Sources.
|
||||
*
|
||||
* Zwei Dinge gelten auf jeder Seite und sind der Grund, warum es diese Datei
|
||||
* gibt:
|
||||
*
|
||||
* 1. Der Hintergrund ist durchsichtig. Eine Browser-Source mit weissem
|
||||
* Hintergrund ist der haeufigste Anfaengerfehler, und man sieht ihn erst
|
||||
* in der Aufzeichnung.
|
||||
* 2. Text liegt ueber bewegtem Bild. Reine Farbe reicht dafuer nicht -
|
||||
* jeder Text bekommt eine dunkle Kontur, sonst verschwindet er im hellen
|
||||
* Gameplay. Deshalb `paint-order` und der Schatten unten.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Twitch-Lila wie im Discord-Bauplan, damit Stream und Server zusammengehoeren. */
|
||||
--lila: #a970ff;
|
||||
--lila-tief: #6441a5;
|
||||
--tuerkis: #4fd1c5;
|
||||
--pink: #ff4fa3;
|
||||
--gold: #ffc53d;
|
||||
--gruen: #4ade80;
|
||||
--rot: #ef4444;
|
||||
|
||||
--tinte: #120e1d;
|
||||
--tinte-klar: rgba(18, 14, 29, .72);
|
||||
--schrift: #f6f3ff;
|
||||
--schrift-leise: #c5bde0;
|
||||
|
||||
--rund: 14px;
|
||||
--schatten: 0 12px 40px rgba(0, 0, 0, .55);
|
||||
--glanz: inset 0 1px 0 rgba(255, 255, 255, .12);
|
||||
|
||||
/* Segoe UI Variable gibt es auf Windows 11 - Abdus Rechner. Der Rest der
|
||||
* Liste ist Rueckfall fuer Entwicklung auf anderen Systemen. */
|
||||
--font: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif;
|
||||
--font-zahl: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: var(--font);
|
||||
color: var(--schrift);
|
||||
/* OBS rendert mit eigener Skalierung; ohne das wirken Kanten weich. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.kontur {
|
||||
paint-order: stroke fill;
|
||||
-webkit-text-stroke: 4px rgba(0, 0, 0, .55);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, .7);
|
||||
}
|
||||
|
||||
/* Glasplatte: der Traeger fuer alles, was ueber dem Spiel steht. */
|
||||
.glas {
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: var(--rund);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
}
|
||||
|
||||
/* Der Lichtstreifen, der einmal ueber eine Flaeche wandert. Wird von den
|
||||
* Einblendungen benutzt und ist der Grund, warum eine Einblendung "teuer"
|
||||
* aussieht, ohne dass ein Video dahintersteckt. */
|
||||
@keyframes streifen {
|
||||
from { transform: translateX(-120%) skewX(-18deg); }
|
||||
to { transform: translateX(320%) skewX(-18deg); }
|
||||
}
|
||||
|
||||
.streifen::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 28%;
|
||||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .3), transparent);
|
||||
animation: streifen 1.5s cubic-bezier(.3, 0, .2, 1) .35s both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulsieren {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .55; transform: scale(.82); }
|
||||
}
|
||||
|
||||
/* Zum Ansehen im normalen Browser: ?probe=1 legt einen dunklen Grund unter
|
||||
* die Seite. Ohne den steht heller Text auf Weiss, und man sieht nichts -
|
||||
* in OBS liegt das Overlay ueber dem Spiel, im Browser ueber gar nichts. */
|
||||
body.probe {
|
||||
background:
|
||||
radial-gradient(60% 60% at 30% 25%, #2a1f45, transparent 70%),
|
||||
radial-gradient(50% 50% at 75% 70%, #123c3a, transparent 70%),
|
||||
#0b0912;
|
||||
}
|
||||
|
||||
/* Wer im Browser nachsieht, will nicht raten, ob die Verbindung steht.
|
||||
* In OBS ist der Punkt unsichtbar, weil er nur bei Verlust erscheint. */
|
||||
.getrennt {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
font: 500 12px/1 var(--font-zahl);
|
||||
color: var(--rot);
|
||||
background: rgba(0, 0, 0, .7);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
opacity: 0;
|
||||
transition: opacity .4s;
|
||||
}
|
||||
body.offline .getrennt { opacity: 1; }
|
||||
|
||||
/* Wer mit prefers-reduced-motion arbeitet, bekommt in OBS trotzdem die
|
||||
* Animationen - eine Browser-Source hat keinen Menschen mit Vestibular-
|
||||
* problemen davor. Die Einstellung wird hier bewusst nicht beachtet. */
|
||||
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Vorlesen</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Die Seite zeigt fast nichts. Sie ist eine Tonquelle mit einer kleinen
|
||||
* Sichtkontrolle daneben - man soll sehen, was gerade gesprochen wird, und
|
||||
* zwar auch als Zuschauer. Wer nur hoert, was vorgelesen wird, kann nicht
|
||||
* nachlesen, wenn er es nicht verstanden hat. */
|
||||
#kasten {
|
||||
position: fixed;
|
||||
left: 16px; right: 16px; bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition: opacity .3s, transform .3s;
|
||||
}
|
||||
#kasten.an { opacity: 1; transform: none; }
|
||||
|
||||
.wer {
|
||||
font-size: 13px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--tuerkis);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.was { font-size: 22px; line-height: 1.35; }
|
||||
|
||||
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
|
||||
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
|
||||
#fehlt {
|
||||
position: fixed;
|
||||
left: 16px; top: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239, 68, 68, .9);
|
||||
font: 600 15px/1.3 var(--font-zahl);
|
||||
max-width: 520px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fehlt">
|
||||
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
|
||||
Vorgelesen wird nichts.
|
||||
</div>
|
||||
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var kasten = document.getElementById('kasten');
|
||||
var werEl = document.getElementById('wer');
|
||||
var wasEl = document.getElementById('was');
|
||||
|
||||
var sprache = Overlay.param('sprache', 'de-DE');
|
||||
var tempo = Overlay.param('tempo', 1);
|
||||
var lautstaerke = Overlay.param('laut', 1);
|
||||
var warteschlange = [];
|
||||
var laeuft = false;
|
||||
|
||||
/* Ob die Browser-Quelle ueberhaupt sprechen kann, weiss man erst zur
|
||||
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
|
||||
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
|
||||
* warum. */
|
||||
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
|
||||
typeof window.SpeechSynthesisUtterance !== 'undefined';
|
||||
if (!kannSprechen) {
|
||||
document.getElementById('fehlt').style.display = 'block';
|
||||
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
|
||||
}
|
||||
|
||||
/* Stimmen werden asynchron geladen. Beim ersten Aufruf ist die Liste oft
|
||||
* noch leer - deshalb bei jedem Sprechen neu nachsehen statt einmal am
|
||||
* Anfang. */
|
||||
function stimme() {
|
||||
if (!kannSprechen) return null;
|
||||
var alle = window.speechSynthesis.getVoices() || [];
|
||||
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
|
||||
return passend[0] || alle[0] || null;
|
||||
}
|
||||
|
||||
function zeige(name, text) {
|
||||
werEl.textContent = name;
|
||||
wasEl.textContent = text;
|
||||
kasten.classList.add('an');
|
||||
}
|
||||
function verstecke() { kasten.classList.remove('an'); }
|
||||
|
||||
function naechste() {
|
||||
var n = warteschlange.shift();
|
||||
if (!n) { laeuft = false; verstecke(); return; }
|
||||
laeuft = true;
|
||||
zeige(n.name, n.text);
|
||||
|
||||
if (!kannSprechen) {
|
||||
// Wenigstens anzeigen, und nach der geschaetzten Lesedauer weiter.
|
||||
setTimeout(naechste, Math.min(15000, 2000 + n.text.length * 60));
|
||||
return;
|
||||
}
|
||||
|
||||
var rede = new SpeechSynthesisUtterance(n.text);
|
||||
rede.lang = sprache;
|
||||
rede.rate = tempo;
|
||||
rede.volume = lautstaerke;
|
||||
var v = stimme();
|
||||
if (v) rede.voice = v;
|
||||
|
||||
var weiter = false;
|
||||
var gehWeiter = function () {
|
||||
if (weiter) return;
|
||||
weiter = true;
|
||||
setTimeout(naechste, 400);
|
||||
};
|
||||
rede.onend = gehWeiter;
|
||||
rede.onerror = function (e) {
|
||||
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||
gehWeiter();
|
||||
};
|
||||
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
|
||||
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
|
||||
setTimeout(gehWeiter, 5000 + n.text.length * 250);
|
||||
|
||||
window.speechSynthesis.speak(rede);
|
||||
}
|
||||
|
||||
Overlay.on('tts', function (ev) {
|
||||
warteschlange.push({ name: ev.name, text: ev.text });
|
||||
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
||||
if (!laeuft) naechste();
|
||||
});
|
||||
|
||||
// Panikschalter: sofort still, Warteschlange weg.
|
||||
Overlay.on('still', function () {
|
||||
warteschlange.length = 0;
|
||||
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
|
||||
laeuft = false;
|
||||
verstecke();
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden NICHT nachholen: eine Nachricht von vor zehn
|
||||
// Minuten vorzulesen ist schlimmer, als sie zu verpassen.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
|
||||
id: `m-${Math.random().toString(36).slice(2)}`,
|
||||
ts: '2026-09-09T20:00:00.000Z',
|
||||
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
|
||||
text: 'moin', emotes: [], isFirst: false, isReturning: false,
|
||||
text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false,
|
||||
isCommand: false, isMod: false, isSub: false, isBroadcaster: false,
|
||||
bits: 0, color: null, ...over,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,11 @@ export type ChatMessage = {
|
||||
displayName: string;
|
||||
text: string;
|
||||
emotes: Array<{ id: string; name: string; count: number }>;
|
||||
/**
|
||||
* GIFs aus Twitchs Auswahlfenster. `from`/`to` zeigen auf die Zeichen im
|
||||
* Text, an denen der Platzhalter steht - siehe `parseGifs` in #twitch/irc.ts.
|
||||
*/
|
||||
gifs: Array<{ id: string; url: string; from: number; to: number }>;
|
||||
isFirst: boolean; // first-msg Tag
|
||||
isReturning: boolean; // returning-chatter Tag
|
||||
isCommand: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLine, toChatMessage } from './irc.ts';
|
||||
import { parseLine, toChatMessage, parseGifs } from './irc.ts';
|
||||
|
||||
const LINE =
|
||||
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
|
||||
@@ -57,3 +57,50 @@ test('PING wird als solches erkannt', () => {
|
||||
assert.equal(m?.command, 'PING');
|
||||
assert.equal(m?.params[0], 'tmi.twitch.tv');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- GIFs
|
||||
|
||||
test('der gifs-Tag wird zerlegt', () => {
|
||||
// Echte Zeile aus #abdulive_, mitgeschnitten am 2026-09-17.
|
||||
const g = parseGifs(
|
||||
'0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/Pnd7qLFuGCOntZcNKB/giphy.gif?cid=095d&ct=g');
|
||||
assert.equal(g.length, 1);
|
||||
assert.equal(g[0]!.id, 'Pnd7qLFuGCOntZcNKB');
|
||||
assert.equal(g[0]!.from, 0);
|
||||
assert.equal(g[0]!.to, 22);
|
||||
assert.match(g[0]!.url, /^https:\/\/media0\.giphy\.com\//);
|
||||
assert.ok(g[0]!.url.includes('&ct=g'), 'die Adresse darf nicht am & abgeschnitten werden');
|
||||
});
|
||||
|
||||
test('der Bereich deckt genau den Platzhalter ab', () => {
|
||||
// Wenn das nicht stimmt, bleibt ein Rest der eckigen Klammer im Bild stehen.
|
||||
const text = '[Idk GIF by Respective]';
|
||||
const g = parseGifs('0-22|abc|https://x/y.gif')[0]!;
|
||||
assert.equal([...text].slice(g.from, g.to + 1).join(''), text);
|
||||
});
|
||||
|
||||
test('eine Nachricht ohne GIF hat eine leere Liste', () => {
|
||||
const m = parseLine('@id=1;user-id=7;emotes= :a!a@a PRIVMSG #k :moin');
|
||||
assert.deepEqual(toChatMessage(m!)!.gifs, []);
|
||||
});
|
||||
|
||||
test('eine Nachricht mit GIF traegt es bis in die ChatMessage', () => {
|
||||
const m = parseLine(
|
||||
'@id=1;user-id=7;emotes=;gifs=0-4|xy|https://media.giphy.com/a.gif ' +
|
||||
':a!a@a PRIVMSG #k :[GIF]');
|
||||
assert.equal(toChatMessage(m!)!.gifs[0]!.url, 'https://media.giphy.com/a.gif');
|
||||
});
|
||||
|
||||
test('kein https, kein Bild', () => {
|
||||
// Der Tag kommt von Twitch, nicht vom Chatter - trotzdem landet die Adresse
|
||||
// als Bild im Stream, und dort gilt kein Vertrauensvorschuss.
|
||||
assert.deepEqual(parseGifs('0-4|xy|http://media.giphy.com/a.gif'), []);
|
||||
assert.deepEqual(parseGifs('0-4|xy|javascript:alert(1)'), []);
|
||||
});
|
||||
|
||||
test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
|
||||
for (const kaputt of ['', '0-4', '0-4|nurid', 'abc|x|https://y/z.gif', '-1-4|x|https://y/z.gif',
|
||||
'9-4|x|https://y/z.gif']) {
|
||||
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
|
||||
}
|
||||
});
|
||||
@@ -52,6 +52,48 @@ export function parseLine(line: string): IrcMessage | null {
|
||||
return { tags, prefix, command, params: parts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Der `gifs`-Tag. Twitch hat ihn 2026 mit der GIF-Auswahl im Chat eingefuehrt.
|
||||
*
|
||||
* Aufbau, am echten Chat abgelesen:
|
||||
*
|
||||
* gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif?cid=...
|
||||
* ^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
* Bereich Giphy-ID Adresse des Bildes
|
||||
*
|
||||
* Der Bereich zeigt wie bei `emotes` auf Zeichen im Nachrichtentext - dort
|
||||
* steht ein Platzhalter wie `[Idk GIF by Respective]`, der ersetzt gehoert.
|
||||
* Wer den Tag nicht auswertet, zeigt genau diesen Platzhalter an.
|
||||
*
|
||||
* Ob mehrere GIFs je Nachricht moeglich sind, ist offen: der Trenner koennte
|
||||
* dafuer nicht `/` sein wie bei Emotes, weil eine URL selbst Schraegstriche
|
||||
* enthaelt. Beobachtet wurde bisher genau eines, und danach ist das hier
|
||||
* gebaut - mehr wuerde ignoriert statt falsch dargestellt.
|
||||
*/
|
||||
export function parseGifs(tag: string | undefined): ChatMessage['gifs'] {
|
||||
if (!tag) return [];
|
||||
const teile = tag.split('|');
|
||||
if (teile.length < 3) return [];
|
||||
|
||||
// Streng auf Ziffern pruefen und nicht auf `Number` vertrauen: `-1-4`
|
||||
// zerfaellt beim Trennen in ['', '1', '4'], und `Number('')` ist 0. Der
|
||||
// Bereich saehe dann gueltig aus und wuerde den falschen Textteil ersetzen.
|
||||
const bereich = /^(\d+)-(\d+)$/.exec(teile[0] ?? '');
|
||||
if (!bereich) return [];
|
||||
const from = Number(bereich[1]);
|
||||
const to = Number(bereich[2]);
|
||||
const id = teile[1] ?? '';
|
||||
// Alles ab dem zweiten Strich ist die Adresse - falls dort je einer auftaucht.
|
||||
const url = teile.slice(2).join('|');
|
||||
|
||||
if (to < from) return [];
|
||||
// Nur https. Der Tag kommt von Twitch und nicht vom Chatter, aber er landet
|
||||
// als Bildadresse im Stream - das ist kein Ort fuer Vertrauensvorschuss.
|
||||
if (!url.startsWith('https://')) return [];
|
||||
|
||||
return [{ id, url, from, to }];
|
||||
}
|
||||
|
||||
function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes'] {
|
||||
if (!tag) return [];
|
||||
const out: ChatMessage['emotes'] = [];
|
||||
@@ -84,6 +126,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
|
||||
displayName: m.tags['display-name'] || login,
|
||||
text,
|
||||
emotes: parseEmotes(m.tags['emotes'], text),
|
||||
gifs: parseGifs(m.tags['gifs']),
|
||||
isFirst: m.tags['first-msg'] === '1',
|
||||
isReturning: m.tags['returning-chatter'] === '1',
|
||||
isCommand: text.startsWith('!'),
|
||||
@@ -95,10 +138,90 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein USERNOTICE ist alles, was Twitch als Ereignis in den Chat schreibt:
|
||||
* Abos, verschenkte Abos, Raids, Ankuendigungen.
|
||||
*
|
||||
* Warum hier und nicht ueber EventSub: es kommt ohnehin ueber dieselbe
|
||||
* Verbindung, kostet keinen zusaetzlichen Token und keine zweite
|
||||
* Wiederverbindung. Was IRC NICHT liefert, sind Follows - dafuer braucht es
|
||||
* EventSub, und bis dahin bleibt das Overlay bei Follows stumm.
|
||||
*/
|
||||
export type UserNoticeKind =
|
||||
| 'sub' | 'resub' | 'subgift' | 'massgift' | 'raid' | 'announcement' | 'other';
|
||||
|
||||
export type UserNotice = {
|
||||
kind: UserNoticeKind;
|
||||
/** Roher msg-id-Wert, falls Twitch etwas Neues schickt. */
|
||||
rawKind: string;
|
||||
ts: string;
|
||||
login: string;
|
||||
displayName: string;
|
||||
/** Twitchs eigener Satz dazu, schon fertig formuliert. */
|
||||
systemMessage: string;
|
||||
/** Was die Person selbst dazugeschrieben hat. Oft leer. */
|
||||
text: string;
|
||||
months?: number;
|
||||
/** Prime, 1000, 2000, 3000 - so wie Twitch es nennt. */
|
||||
tier?: string;
|
||||
/** Beschenkte Person bei subgift. */
|
||||
recipient?: string;
|
||||
/** Anzahl: verschenkte Abos oder mitgebrachte Zuschauer beim Raid. */
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const NOTICE_KINDS: Record<string, UserNoticeKind> = {
|
||||
sub: 'sub',
|
||||
resub: 'resub',
|
||||
subgift: 'subgift',
|
||||
submysterygift: 'massgift',
|
||||
raid: 'raid',
|
||||
announcement: 'announcement',
|
||||
};
|
||||
|
||||
function num(v: string | undefined): number | undefined {
|
||||
const n = Number(v);
|
||||
return v !== undefined && v !== '' && Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
export function toUserNotice(m: IrcMessage): UserNotice | null {
|
||||
if (m.command !== 'USERNOTICE') return null;
|
||||
const rawKind = m.tags['msg-id'] ?? '';
|
||||
const login = m.tags['login'] ?? '';
|
||||
const months = num(m.tags['msg-param-cumulative-months']) ?? num(m.tags['msg-param-months']);
|
||||
// Beim Raid steht die Zuschauerzahl drin, beim Massengeschenk die Anzahl.
|
||||
const count = num(m.tags['msg-param-viewerCount']) ?? num(m.tags['msg-param-mass-gift-count']);
|
||||
const recipient = m.tags['msg-param-recipient-display-name'] ||
|
||||
m.tags['msg-param-recipient-user-name'];
|
||||
const tier = m.tags['msg-param-sub-plan'];
|
||||
|
||||
return {
|
||||
kind: NOTICE_KINDS[rawKind] ?? 'other',
|
||||
rawKind,
|
||||
ts: new Date(Number(m.tags['tmi-sent-ts'] ?? Date.now())).toISOString(),
|
||||
login,
|
||||
displayName: m.tags['display-name'] || login,
|
||||
systemMessage: m.tags['system-msg'] ?? '',
|
||||
text: m.params[1] ?? '',
|
||||
...(months !== undefined ? { months } : {}),
|
||||
...(tier ? { tier } : {}),
|
||||
...(recipient ? { recipient } : {}),
|
||||
...(count !== undefined ? { count } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type IrcHandlers = {
|
||||
onMessage: (msg: ChatMessage) => void;
|
||||
onNotice?: (text: string) => void;
|
||||
onConnected?: (anonymous: boolean) => void;
|
||||
/** Abos, Raids, Ankuendigungen. */
|
||||
onUserNotice?: (notice: UserNotice) => void;
|
||||
/**
|
||||
* Eine Nachricht oder ein ganzer Chatter wurde geloescht. Das Overlay muss
|
||||
* das mitbekommen, sonst steht eine geloeschte Beleidigung noch eine Minute
|
||||
* lang im Bild - und zwar in der Aufzeichnung.
|
||||
*/
|
||||
onClear?: (what: { userId?: string; messageId?: string }) => void;
|
||||
};
|
||||
|
||||
export type IrcClient = {
|
||||
@@ -189,6 +312,22 @@ function connectOnce(
|
||||
continue;
|
||||
}
|
||||
if (m.command === 'NOTICE') { handlers.onNotice?.(m.params[1] ?? ''); continue; }
|
||||
if (m.command === 'USERNOTICE') {
|
||||
const notice = toUserNotice(m);
|
||||
if (notice) handlers.onUserNotice?.(notice);
|
||||
continue;
|
||||
}
|
||||
if (m.command === 'CLEARCHAT') {
|
||||
// Ohne target-user-id wurde der ganze Chat geleert.
|
||||
const userId = m.tags['target-user-id'];
|
||||
handlers.onClear?.(userId ? { userId } : {});
|
||||
continue;
|
||||
}
|
||||
if (m.command === 'CLEARMSG') {
|
||||
const messageId = m.tags['target-msg-id'];
|
||||
if (messageId) handlers.onClear?.({ messageId });
|
||||
continue;
|
||||
}
|
||||
if (m.command === 'PRIVMSG') {
|
||||
const msg = toChatMessage(m);
|
||||
if (msg) handlers.onMessage(msg);
|
||||
|
||||
@@ -156,7 +156,10 @@ async function main(): Promise<void> {
|
||||
for (const dir of ['packages', 'apps']) {
|
||||
for (const rel of await walk(path.join(repo, dir))) {
|
||||
if (rel.endsWith('.test.ts')) continue;
|
||||
if (!/\.(ts|sql|json)$/.test(rel)) continue;
|
||||
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
|
||||
// apps/overlay-web/public und werden zur Laufzeit von der Platte
|
||||
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
|
||||
if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue;
|
||||
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user