/* 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 */ } } // `quelle` sagt dem Server, welche Seite zuhoert. Er zaehlt damit nur - // das Pult will wissen, ob die Sound-Quelle in OBS ueberhaupt offen ist. var quelleName = location.pathname.replace(/^\/+|\.html$/g, '') || 'index'; var url = '/events?quelle=' + encodeURIComponent(quelleName) + (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' + 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 += 'GIF'; 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(); }); })();