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>
169 lines
6.4 KiB
JavaScript
169 lines
6.4 KiB
JavaScript
/* Gemeinsamer Anschluss aller Browser-Sources an den Ereignisstrom.
|
|
*
|
|
* Bewusst kein Modul und kein Build: OBS laedt diese Datei direkt, und jede
|
|
* Zwischenstufe waere ein Ding, das im Stream kaputtgehen kann.
|
|
*
|
|
* Die drei Dinge, an denen solche Overlays sonst scheitern:
|
|
*
|
|
* 1. Die Quelle bleibt nach einem Neustart des Dienstes tot. -> EventSource
|
|
* verbindet von allein neu, und ein Wachhund greift ein, wenn selbst das
|
|
* haengt (OBS haelt tote Verbindungen erstaunlich lange fuer lebendig).
|
|
* 2. Die Quelle ist nach dem Neuverbinden leer. -> Der Server schickt als
|
|
* erstes einen Schnappschuss, den die Seiten als `state` bekommen.
|
|
* 3. Chat-Text landet ungeprueft im DOM. -> `Overlay.esc` und `emoteHtml`
|
|
* sind die einzigen Wege, auf denen fremder Text hier hineinkommt.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var params = new URLSearchParams(location.search);
|
|
var handlers = {};
|
|
var quelle = null;
|
|
var letzteNachricht = Date.now();
|
|
|
|
/** Ohne Nachricht in dieser Zeit gilt die Verbindung als tot. Der Server
|
|
* schickt alle 15 s einen Herzschlag, also sind 45 s drei verpasste. */
|
|
var TOT_NACH_MS = 45000;
|
|
|
|
function verbinde() {
|
|
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
|
|
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : '');
|
|
quelle = new EventSource(url);
|
|
letzteNachricht = Date.now();
|
|
|
|
quelle.onopen = function () {
|
|
document.body.classList.remove('offline');
|
|
letzteNachricht = Date.now();
|
|
};
|
|
quelle.onerror = function () {
|
|
document.body.classList.add('offline');
|
|
};
|
|
quelle.onmessage = function (e) {
|
|
letzteNachricht = Date.now();
|
|
document.body.classList.remove('offline');
|
|
var ev;
|
|
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
|
if (ev.kind === 'beat') return;
|
|
var liste = handlers[ev.kind] || [];
|
|
for (var i = 0; i < liste.length; i++) {
|
|
try { liste[i](ev); } catch (err) { console.error('[overlay]', ev.kind, err); }
|
|
}
|
|
};
|
|
}
|
|
|
|
setInterval(function () {
|
|
if (Date.now() - letzteNachricht > TOT_NACH_MS) {
|
|
console.warn('[overlay] nichts mehr gehoert, verbinde neu');
|
|
verbinde();
|
|
}
|
|
}, 10000);
|
|
|
|
window.Overlay = {
|
|
/** Auf eine Ereignisart hoeren. Mehrere Hoerer je Art sind erlaubt. */
|
|
on: function (kind, fn) {
|
|
(handlers[kind] = handlers[kind] || []).push(fn);
|
|
return window.Overlay;
|
|
},
|
|
|
|
/** Einstellung aus der Adresse, z. B. ?sekunden=60. */
|
|
param: function (name, fallback) {
|
|
var v = params.get(name);
|
|
if (v === null || v === '') return fallback;
|
|
if (typeof fallback === 'number') {
|
|
var n = Number(v);
|
|
return isFinite(n) ? n : fallback;
|
|
}
|
|
if (typeof fallback === 'boolean') return v !== '0' && v !== 'false' && v !== 'nein';
|
|
return v;
|
|
},
|
|
|
|
/** Der einzige erlaubte Weg, fremden Text ins DOM zu bringen. */
|
|
esc: function (s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Text mit eingesetzten Emotes. Erst maskieren, dann ersetzen - in der
|
|
* anderen Reihenfolge waere jeder Emote-Name ein Einfallstor.
|
|
*
|
|
* Twitch liefert nur Name und ID, nicht die Position. Ersetzt wird
|
|
* deshalb der Name als ganzes Wort. BTTV und 7TV sind nicht dabei; die
|
|
* haengen an einer zweiten API und waeren ein eigenes Stueck Arbeit.
|
|
*/
|
|
emoteHtml: function (text, emotes) {
|
|
var html = window.Overlay.esc(text);
|
|
if (!emotes || !emotes.length) return html;
|
|
for (var i = 0; i < emotes.length; i++) {
|
|
var e = emotes[i];
|
|
if (!e.id || !e.name) continue;
|
|
var sicher = window.Overlay.esc(e.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
var src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
|
encodeURIComponent(e.id) + '/default/dark/2.0';
|
|
html = html.replace(new RegExp('(^|\\s)' + sicher + '(?=\\s|$)', 'g'),
|
|
'$1<img class="emote" src="' + src + '" alt="' + window.Overlay.esc(e.name) + '">');
|
|
}
|
|
return html;
|
|
},
|
|
|
|
/**
|
|
* Nachrichtentext mit eingesetzten GIFs und Emotes.
|
|
*
|
|
* GIFs kommen anders als Emotes mit einem Zeichenbereich: im Text steht
|
|
* nur ein Platzhalter wie "[Idk GIF by Respective]", und der gehoert
|
|
* ersetzt. Wer nur den Text anzeigt, zeigt genau diese eckige Klammer.
|
|
*/
|
|
nachrichtHtml: function (text, gifs, emotes) {
|
|
if (!gifs || !gifs.length) return window.Overlay.emoteHtml(text, emotes);
|
|
|
|
// Nach Zeichen zerlegen, nicht nach Bytes: die Bereiche zaehlen
|
|
// Zeichen, und ein Emoji im Text wuerde die Rechnung sonst verschieben.
|
|
var chars = Array.from(text);
|
|
var sortiert = gifs.slice().sort(function (a, b) { return a.from - b.from; });
|
|
var html = '';
|
|
var pos = 0;
|
|
|
|
for (var i = 0; i < sortiert.length; i++) {
|
|
var g = sortiert[i];
|
|
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') continue;
|
|
if (g.from > pos) {
|
|
html += window.Overlay.emoteHtml(chars.slice(pos, g.from).join(''), emotes);
|
|
}
|
|
html += '<img class="gif" src="' + window.Overlay.esc(g.url) + '" alt="GIF"' +
|
|
' onerror="this.replaceWith(document.createTextNode(this.alt))">';
|
|
pos = Math.max(pos, g.to + 1);
|
|
}
|
|
if (pos < chars.length) {
|
|
html += window.Overlay.emoteHtml(chars.slice(pos).join(''), emotes);
|
|
}
|
|
return html;
|
|
},
|
|
|
|
/** "2 Std 14 Min" aus einem ISO-Zeitpunkt. */
|
|
laufzeit: function (seit) {
|
|
var ms = Date.now() - Date.parse(seit);
|
|
if (!isFinite(ms) || ms < 0) return '';
|
|
var m = Math.floor(ms / 60000);
|
|
var h = Math.floor(m / 60);
|
|
return h > 0 ? h + ':' + String(m % 60).padStart(2, '0') + ' Std'
|
|
: m + ' Min';
|
|
},
|
|
|
|
/** Zahl mit Tausenderpunkt, wie man sie in Deutschland liest. */
|
|
zahl: function (n) {
|
|
return Number(n || 0).toLocaleString('de-DE');
|
|
},
|
|
};
|
|
|
|
// Der Punkt unten rechts, der nur bei Verlust erscheint.
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (params.get('probe')) document.body.classList.add('probe');
|
|
var d = document.createElement('div');
|
|
d.className = 'getrennt';
|
|
d.textContent = 'keine Verbindung';
|
|
document.body.appendChild(d);
|
|
verbinde();
|
|
});
|
|
})();
|