/* 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
');
}
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 += '
';
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();
});
})();