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:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:13:28 +02:00
1 parent 3b606e36fb
commit bc2c8f6cbe
24 files changed
+3221 -8

No files matched your search

+1 -1
View File
@@ -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,
};
+47 -2
View File
@@ -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.
+68
View File
@@ -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));
});
+184
View File
@@ -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) };
});
}
+110
View File
@@ -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);
});
+155
View File
@@ -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,
};
}
+56
View File
@@ -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 });
}
+627
View File
@@ -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">&nbsp;</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} &times; ${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>
&nbsp;·&nbsp; ${bus.clients()} verbundene Quelle(n)
${opts.setup ? '&nbsp;·&nbsp; <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
(&bdquo;ansehen&ldquo; 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">&nbsp;</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 = '&nbsp;'; 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 = '&nbsp;'; }, 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]!));
}
+117
View File
@@ -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);
});
+203
View File
@@ -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
View File
@@ -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.
+222
View File
@@ -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>
+196
View File
@@ -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>
+168
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
});
})();
+194
View File
@@ -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>
+156
View File
@@ -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>
+141
View File
@@ -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>
+122
View File
@@ -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. */
+157
View File
@@ -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>