/* Das Pult: Soundboard, Szenen, Chat, Liegestuetze, Vorlesen - am Handy, * ab Tablet-Groesse mit dem Chat daneben. * * Zwei Wege zum Hub: * - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei * offene Pulte und die Uebersichtsseite nicht auseinanderlaufen; * - einfache POSTs fuer jeden Druck. Jede Antwort sagt, ob es geklappt * hat, und genau das steht danach an der Taste. * * Fremder Text (Soundnamen, Szenen, Chat) kommt nur ueber textContent ins * DOM, nie ueber innerHTML. */ (function () { 'use strict'; var $ = function (id) { return document.getElementById(id); }; /** Gleiche Grenze wie die Media Query in pult.css. */ var geteilt = window.matchMedia('(min-width: 760px) and (min-height: 500px)'); var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau']; var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉']; var zustand = { sounds: [], obs: { pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false }, live: false, bearbeiten: false, ttsAn: false, }; // ------------------------------------------------------------ Hilfen function el(tag, klasse, text) { var e = document.createElement(tag); if (klasse) e.className = klasse; if (text !== undefined) e.textContent = text; return e; } function tippen() { if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) { /* egal */ } } } var meldungTimer; function melden(text, art) { var m = $('meldung'); if (!text) return; m.textContent = text; m.className = 'meldung an' + (art ? ' ' + art : ''); clearTimeout(meldungTimer); meldungTimer = setTimeout(function () { m.className = 'meldung'; }, art === 'fehler' ? 4500 : 2200); } /** POST mit Formular, Antwort als JSON. Wirft nie; Fehler stehen in `ok`. */ function rufen(pfad, felder, roh) { var opts = { method: 'POST', credentials: 'same-origin' }; if (roh) { opts.body = roh; } else { opts.headers = { 'content-type': 'application/x-www-form-urlencoded' }; opts.body = new URLSearchParams(felder || {}); } return fetch(pfad, opts).then(function (res) { return res.json().catch(function () { return { text: 'Fehler ' + res.status }; }) .then(function (body) { if (res.status === 401) abgemeldet(); body.ok = res.ok && body.ok !== false; return body; }); }, function () { return { ok: false, text: 'Keine Verbindung zum Hub.' }; }); } function holen(pfad) { return fetch(pfad, { credentials: 'same-origin' }).then(function (res) { if (res.status === 401) { abgemeldet(); throw new Error('abgemeldet'); } if (!res.ok) throw new Error('Fehler ' + res.status); return res.json(); }); } function abgemeldet() { $('abgemeldet').hidden = false; } /** Taste waehrend eines Rufs sperren, damit ein Doppeltipp nicht doppelt schaltet. */ function mitWarten(knopf, versprechen) { knopf.classList.add('wartet'); return versprechen.then(function (r) { knopf.classList.remove('wartet'); return r; }); } // ------------------------------------------------------------ Reiter function zeigeReiter(name) { // Ab Tablet-Breite steht der Chat ohnehin daneben, links bleibt dann // Platz fuer das, womit man ihn zusammen braucht. if (name === 'chat' && geteilt.matches) name = 'szenen'; var reiter = document.querySelectorAll('.reiter button'); for (var i = 0; i < reiter.length; i++) { reiter[i].classList.toggle('an', reiter[i].dataset.tab === name); } var tabs = document.querySelectorAll('.tab'); for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name); try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ } if (name === 'stream') ladeWartend(); if (name === 'chat') chatGesehen(); window.scrollTo(0, 0); } document.querySelector('.reiter').addEventListener('click', function (e) { var b = e.target.closest('button[data-tab]'); if (b) { tippen(); zeigeReiter(b.dataset.tab); } }); // ------------------------------------------------------------ Kopf var laufzeitTimer; function zeigeStream(s) { zustand.live = !!s.live; var live = $('live'); live.classList.toggle('aus', !s.live); clearInterval(laufzeitTimer); var schreiben = function () { if (!s.live) { $('live-text').textContent = 'offline'; return; } var teile = ['LIVE']; if (s.startedAt) { var min = Math.max(0, Math.floor((Date.now() - Date.parse(s.startedAt)) / 60000)); teile.push(Math.floor(min / 60) + ':' + String(min % 60).padStart(2, '0')); } teile.push((s.viewers || 0).toLocaleString('de-DE') + ' 👁'); $('live-text').textContent = teile.join(' · '); }; schreiben(); if (s.live) laufzeitTimer = setInterval(schreiben, 30000); $('test-live').hidden = !s.live; var gesperrt = document.querySelectorAll('#test-gruppen [data-offline]'); for (var i = 0; i < gesperrt.length; i++) gesperrt[i].disabled = !!s.live; var demo = $('demo'); if (demo.dataset.art === 'demo-an') demo.disabled = !!s.live; } // Bildschirm anlassen. Ein Pult, das nach 30 s schwarz wird, ist mitten // im Stream keins. Der Wunsch wird gemerkt; das Handy gibt die Sperre bei // jedem Wegwischen frei, also wird sie beim Zurueckkommen neu geholt. var sperre = null; var wachGewuenscht = false; try { wachGewuenscht = localStorage.getItem('pult-wach') === '1'; } catch (e) { /* privat */ } function wachHolen() { if (!wachGewuenscht || !('wakeLock' in navigator) || document.visibilityState !== 'visible') { $('wach').setAttribute('aria-pressed', String(!!sperre)); return; } navigator.wakeLock.request('screen').then(function (s) { sperre = s; $('wach').setAttribute('aria-pressed', 'true'); s.addEventListener('release', function () { sperre = null; $('wach').setAttribute('aria-pressed', 'false'); }); }, function () { $('wach').setAttribute('aria-pressed', 'false'); }); } $('wach').addEventListener('click', function () { tippen(); if (!('wakeLock' in navigator)) { melden('Dieser Browser kann den Bildschirm nicht wach halten.', 'fehler'); return; } wachGewuenscht = !sperre; try { localStorage.setItem('pult-wach', wachGewuenscht ? '1' : '0'); } catch (e) { /* privat */ } if (sperre) { sperre.release(); sperre = null; melden('Bildschirm darf wieder ausgehen.'); } else { wachHolen(); melden('Bildschirm bleibt an.', 'ok'); } }); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { wachHolen(); ladeSounds(); } }); // ------------------------------------------------------------ Sounds function ladeSounds() { return holen('/soundboard/liste').then(zeigeSounds, function () { /* Meldung kommt woanders */ }); } function zeigeSounds(body) { if (body.sounds) zustand.sounds = body.sounds; if (typeof body.quellen === 'number') $('quelle-fehlt').hidden = body.quellen > 0; var raster = $('raster'); raster.textContent = ''; raster.classList.toggle('bearbeiten', zustand.bearbeiten); $('sounds-leer').hidden = zustand.sounds.length > 0; $('bearbeiten').hidden = zustand.sounds.length === 0; if (!zustand.sounds.length && zustand.bearbeiten) setzeBearbeiten(false); zustand.sounds.forEach(function (s) { var k = el('button', 'kachel' + (s.emoji ? '' : ' ohne-emoji')); k.type = 'button'; k.dataset.id = s.id; k.dataset.farbe = FARBEN.indexOf(s.farbe) !== -1 ? s.farbe : 'lila'; if (s.emoji) k.appendChild(el('span', 'emoji', s.emoji)); k.appendChild(el('span', 'name', s.name)); k.setAttribute('aria-label', (zustand.bearbeiten ? 'Bearbeiten: ' : 'Abspielen: ') + s.name); raster.appendChild(k); }); } function knall(id) { var k = document.querySelector('.kachel[data-id="' + CSS.escape(id) + '"]'); if (!k) return; k.classList.remove('knall'); void k.offsetWidth; // Animation neu starten, auch beim Doppeltipp k.classList.add('knall'); } $('raster').addEventListener('click', function (e) { var k = e.target.closest('.kachel'); if (!k) return; tippen(); var id = k.dataset.id; if (zustand.bearbeiten) { oeffneBlatt(id); return; } knall(id); rufen('/soundboard/spielen', { id: id }).then(function (r) { if (!r.ok) { melden(r.text, 'fehler'); return; } $('quelle-fehlt').hidden = r.quellen > 0; if (!r.quellen) melden(r.text, 'fehler'); }); }); $('stopp').addEventListener('click', function () { tippen(); rufen('/soundboard/stopp').then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); }); }); function setzeBearbeiten(an) { zustand.bearbeiten = an; $('bearbeiten').setAttribute('aria-pressed', String(an)); $('bearbeiten').textContent = an ? 'Fertig' : 'Bearbeiten'; $('raster').classList.toggle('bearbeiten', an); var kacheln = document.querySelectorAll('.kachel'); for (var i = 0; i < kacheln.length; i++) { var s = zustand.sounds[i]; if (s) kacheln[i].setAttribute('aria-label', (an ? 'Bearbeiten: ' : 'Abspielen: ') + s.name); } } $('bearbeiten').addEventListener('click', function () { tippen(); setzeBearbeiten(!zustand.bearbeiten); }); // Hochladen: roh im Koerper, eine Datei nach der anderen. Parallel waere // schneller, aber dann kommt die Reihenfolge durcheinander. $('datei').addEventListener('change', function (e) { var dateien = Array.prototype.slice.call(e.target.files || []); e.target.value = ''; var weiter = Promise.resolve(); dateien.forEach(function (d, i) { weiter = weiter.then(function () { melden((dateien.length > 1 ? (i + 1) + '/' + dateien.length + ' · ' : '') + d.name + ' wird hochgeladen …'); return rufen('/soundboard/neu?name=' + encodeURIComponent(d.name), null, d).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); if (r.sounds) zeigeSounds(r); // Der erste Sound geht gleich ins Bearbeiten: ein Emoji macht aus // einer Liste von Dateinamen erst ein Pult. if (r.ok && r.sound && dateien.length === 1) oeffneBlatt(r.sound.id); }); }); }); }); // ------------------------------------------------------------ Blatt var blatt = $('blatt'); var offenId = null; var probe = null; (function baueBlatt() { var schnell = $('b-schnell'); SCHNELL.forEach(function (em) { var b = el('button', '', em); b.type = 'button'; b.addEventListener('click', function () { $('b-emoji').value = em; tippen(); }); schnell.appendChild(b); }); var farben = $('b-farben'); FARBEN.forEach(function (f) { var b = el('button', 'farbe'); b.type = 'button'; b.dataset.farbe = f; b.setAttribute('role', 'radio'); b.setAttribute('aria-label', f); b.addEventListener('click', function () { waehleFarbe(f); tippen(); }); farben.appendChild(b); }); })(); function waehleFarbe(f) { var alle = $('b-farben').children; for (var i = 0; i < alle.length; i++) alle[i].setAttribute('aria-checked', String(alle[i].dataset.farbe === f)); blatt.dataset.farbe = f; } function oeffneBlatt(id) { var s = zustand.sounds.find(function (x) { return x.id === id; }); if (!s) return; offenId = id; $('b-name').value = s.name; $('b-emoji').value = s.emoji || ''; $('b-laut').value = Math.round((s.laut ?? 0.8) * 100); $('b-laut-zahl').textContent = $('b-laut').value + ' %'; waehleFarbe(s.farbe); $('b-loeschen').textContent = 'Löschen'; $('b-loeschen').dataset.sicher = ''; if (typeof blatt.showModal === 'function') blatt.showModal(); else blatt.setAttribute('open', ''); } function schliesseBlatt() { if (probe) { probe.pause(); probe = null; } offenId = null; if (blatt.open) blatt.close(); } $('b-laut').addEventListener('input', function () { $('b-laut-zahl').textContent = $('b-laut').value + ' %'; if (probe) probe.volume = Number($('b-laut').value) / 100; }); $('b-hoeren').addEventListener('click', function () { if (!offenId) return; if (probe) { probe.pause(); probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; return; } probe = new Audio('/soundboard/datei/' + encodeURIComponent(offenId)); probe.volume = Number($('b-laut').value) / 100; $('b-hoeren').textContent = 'Anhören stoppen'; probe.onended = function () { probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; }; probe.play().catch(function () { melden('Abspielen am Handy ging nicht.', 'fehler'); }); }); function verschieben(um) { if (!offenId) return; tippen(); rufen('/soundboard/verschieben', { id: offenId, um: String(um) }).then(function (r) { if (r.sounds) zeigeSounds(r); }); } $('b-vor').addEventListener('click', function () { verschieben(-1); }); $('b-zurueck').addEventListener('click', function () { verschieben(1); }); $('b-loeschen').addEventListener('click', function () { var b = $('b-loeschen'); // Zweimal tippen statt confirm(): ein Dialog des Browsers sieht am Handy // aus wie eine Fehlermeldung, und er haelt die Seite an. if (!b.dataset.sicher) { b.dataset.sicher = '1'; b.textContent = 'Wirklich löschen?'; tippen(); return; } var id = offenId; schliesseBlatt(); rufen('/soundboard/loeschen', { id: id }).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); if (r.sounds) zeigeSounds(r); }); }); $('b-abbrechen').addEventListener('click', schliesseBlatt); blatt.addEventListener('close', function () { if (probe) { probe.pause(); probe = null; } }); blatt.addEventListener('click', function (e) { if (e.target === blatt) schliesseBlatt(); }); $('blatt-form').addEventListener('submit', function (e) { e.preventDefault(); var id = offenId; if (!id) return; var felder = { id: id, name: $('b-name').value, emoji: $('b-emoji').value, farbe: blatt.dataset.farbe || 'lila', laut: String(Number($('b-laut').value) / 100), }; schliesseBlatt(); rufen('/soundboard/aendern', felder).then(function (r) { if (!r.ok) melden(r.text, 'fehler'); if (r.sounds) zeigeSounds(r); }); }); // ------------------------------------------------------------ OBS function zeigeObs(o) { zustand.obs = o; var fehlt = !o.pc || !o.obs; $('obs-fehlt').hidden = !fehlt; $('obs-da').hidden = fehlt; if (!o.pc) { $('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden'; $('obs-fehlt-text').textContent = 'Sobald abdulive auf dem Stream-PC läuft, meldet er sich hier ' + 'und du tippst einmal auf „Freigeben“. Läuft er schon, ist er vielleicht aus oder offline.'; } else if (!o.obs) { $('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar'; $('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' + 'obs-websocket an (Werkzeuge → WebSocket-Servereinstellungen)?'; } if (fehlt) return; var szenen = $('szenen'); szenen.textContent = ''; o.szenen.forEach(function (name) { var b = el('button', 'szene' + (name === o.aktuell ? ' aktiv' : '')); b.type = 'button'; b.dataset.szene = name; b.appendChild(el('span', 'szene-name', name)); b.appendChild(el('span', 'marke-an', 'Im Bild')); szenen.appendChild(b); }); if (!o.szenen.length) szenen.appendChild(el('p', 'klein', 'OBS meldet keine Szenen.')); $('clip').disabled = !o.puffer; $('clip-hinweis').textContent = o.puffer ? 'Sichert die letzten Sekunden aus dem Wiedergabepuffer, wie !clip im Chat – ohne Abklingzeit.' : 'Der Wiedergabepuffer läuft nicht. In OBS unten rechts „Wiedergabepuffer starten“.'; var ton = $('ton'); ton.textContent = ''; o.ton.forEach(function (t) { var b = el('button', 'ton-zeile' + (t.stumm ? ' stumm' : '')); b.type = 'button'; b.dataset.quelle = t.name; b.dataset.stumm = t.stumm ? '1' : '0'; b.innerHTML = t.stumm ? '' : ''; b.appendChild(el('span', 'ton-name', t.name)); b.appendChild(el('span', 'ton-stand', t.stumm ? 'stumm' : 'an')); ton.appendChild(b); }); if (!o.ton.length) ton.appendChild(el('p', 'klein', 'Keine Toneingänge gefunden.')); } function obsRuf(knopf, felder) { tippen(); return mitWarten(knopf, rufen('/pult/obs', felder)).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); return r; }); } $('szenen').addEventListener('click', function (e) { var b = e.target.closest('.szene'); if (!b || b.classList.contains('aktiv')) return; obsRuf(b, { was: 'szene', szene: b.dataset.szene }).then(function (r) { // Sofort umfaerben, nicht erst auf den naechsten Stand warten. Der // Stand vom PC kommt hinterher und rueckt es gerade, falls noetig. if (r.ok) zeigeObs(Object.assign({}, zustand.obs, { aktuell: b.dataset.szene })); }); }); $('clip').addEventListener('click', function () { obsRuf($('clip'), { was: 'clip' }); }); $('ton').addEventListener('click', function (e) { var b = e.target.closest('.ton-zeile'); if (!b) return; obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' }); }); // ------------------------------------------------------------ Freigabe // Ein Stream-PC, der sich zum ersten Mal meldet, wartet hier auf einen // Tipp. Name und Adresse kommen vom PC selbst - eine Anfrage, die man nicht // erwartet, lehnt man ab. function zeigeAnfragen(liste) { var box = $('anfragen'); box.textContent = ''; $('nav-fern').hidden = !liste.length; liste.forEach(function (a) { var k = el('div', 'anfrage'); k.appendChild(el('h2', '', 'Stream-PC „' + a.name + '“ möchte sich verbinden')); var seit = new Date(a.seit); k.appendChild(el('p', '', 'Von ' + a.adresse + ', seit ' + seit.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr. Danach steuert das Pult OBS auf diesem Rechner.')); var reihe = el('div', 'reihe'); var ja = el('button', 'knopf primaer', 'Freigeben'); ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = a.id; var nein = el('button', 'knopf', 'Ablehnen'); nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = a.id; reihe.appendChild(ja); reihe.appendChild(nein); k.appendChild(reihe); box.appendChild(k); }); } function ladeAnfragen() { holen('/pult/fern').then(function (b) { zeigeAnfragen(b.anfragen || []); }, function () { /* still */ }); } $('anfragen').addEventListener('click', function (e) { var b = e.target.closest('button[data-was]'); if (!b) return; tippen(); mitWarten(b, rufen('/pult/fern', { was: b.dataset.was, id: b.dataset.id })).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); if (r.anfragen) zeigeAnfragen(r.anfragen); }); }); $('trennen').addEventListener('click', function () { var b = $('trennen'); if (!b.dataset.sicher) { b.dataset.sicher = '1'; b.textContent = 'Wirklich trennen? Danach neu freigeben.'; setTimeout(function () { b.dataset.sicher = ''; b.textContent = 'Stream-PC trennen'; }, 4000); return; } b.dataset.sicher = ''; b.textContent = 'Stream-PC trennen'; rufen('/pult/fern', { was: 'trennen' }).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); }); }); // ------------------------------------------------------------ Liegestuetze function zeigeLz(st) { if (!st) return; $('lz-karte').hidden = false; if (typeof st.offen === 'number') $('lz-offen').textContent = st.offen.toLocaleString('de-DE'); if (typeof st.gemacht === 'number') $('lz-gemacht').textContent = st.gemacht.toLocaleString('de-DE'); } document.querySelector('.lz-knoepfe').addEventListener('click', function (e) { var b = e.target.closest('button[data-um]'); if (!b) return; tippen(); mitWarten(b, rufen('/liegestuetze', { was: 'aendern', wert: b.dataset.um })).then(function (r) { zeigeLz(r); if (!r.ok) melden(r.text, 'fehler'); }); }); // ------------------------------------------------------------ Vorlesen function zeigeWartend(liste) { var box = $('wartend'); box.textContent = ''; $('tts-anzahl').hidden = !liste.length; $('tts-anzahl').textContent = String(liste.length); $('nav-tts').hidden = !liste.length; $('alle-ablehnen').hidden = liste.length < 2; if (!liste.length) { box.appendChild(el('div', 'leer-zeile', 'Nichts wartet auf Freigabe.')); return; } liste.forEach(function (w) { var n = el('div', 'nachricht'); n.appendChild(el('div', 'wer', w.name)); n.appendChild(el('div', 'was', w.text)); var reihe = el('div', 'reihe'); var ja = el('button', 'knopf primaer', 'Vorlesen'); ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = w.id; var nein = el('button', 'knopf', 'Ablehnen'); nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = w.id; reihe.appendChild(ja); reihe.appendChild(nein); n.appendChild(reihe); box.appendChild(n); }); } function ladeWartend() { if (!zustand.ttsAn) return; holen('/tts/wartend').then(function (b) { zeigeWartend(b.wartend || []); }, function () { /* still */ }); } function ttsRuf(was, id) { tippen(); return rufen('/tts', { was: was, id: id || '' }).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); if (r.wartend) zeigeWartend(r.wartend); }); } $('wartend').addEventListener('click', function (e) { var b = e.target.closest('button[data-was]'); if (b) ttsRuf(b.dataset.was, b.dataset.id); }); $('alle-ablehnen').addEventListener('click', function () { ttsRuf('alle-ablehnen'); }); // Der Panikschalter. Ohne Vorlesen haelt er wenigstens die Sounds an. $('still').addEventListener('click', function () { tippen(); if (zustand.ttsAn) { ttsRuf('still'); return; } rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); }); }); // ------------------------------------------------------------ Chat // Nur lesend. Die Nachrichten kommen ueber denselben Ereignisstrom wie bei // der Chat-Browser-Source; das Pult schreibt nie in den Chat und braucht // dafuer weder Bot-Konto noch weitere Rechte bei Twitch. var CHAT_MAX = 250; var chatListe = $('chat-liste'); /** Steht die Liste am Ende? Dann wandert sie mit, sonst nicht. */ var chatKlebt = true; /* Wie in chat.html: dunkle Twitch-Farben aufhellen statt verwerfen. */ 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); return '#' + [r, g, b].map(function (v) { return Math.min(255, Math.round(v * f)).toString(16).padStart(2, '0'); }).join(''); } /** Text mit Emotes als Knoten. Ersetzt wird der Name als ganzes Wort, * wie in client.js - Twitch liefert keine Positionen. */ function emoteKnoten(ziel, text, emotes) { var ids = {}; (emotes || []).forEach(function (e) { if (e && e.id && e.name) ids[e.name] = e.id; }); text.split(/(\s+)/).forEach(function (wort) { if (!wort) return; if (!Object.prototype.hasOwnProperty.call(ids, wort)) { ziel.appendChild(document.createTextNode(wort)); return; } var img = el('img', 'emote'); img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' + encodeURIComponent(ids[wort]) + '/default/dark/2.0'; img.alt = wort; img.title = wort; ziel.appendChild(img); }); } /** GIFs kommen mit Zeichenbereich und ersetzen ihren Platzhalter im Text. */ function textKnoten(ziel, text, gifs, emotes) { var zeichen = Array.from(text); var pos = 0; (gifs || []).slice().sort(function (a, b) { return a.from - b.from; }).forEach(function (g) { if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') return; if (g.from > pos) emoteKnoten(ziel, zeichen.slice(pos, g.from).join(''), emotes); var img = el('img', 'gif'); img.src = g.url; img.alt = 'GIF'; img.onerror = function () { img.replaceWith(document.createTextNode('[GIF]')); }; ziel.appendChild(img); pos = Math.max(pos, g.to + 1); }); if (pos < zeichen.length) emoteKnoten(ziel, zeichen.slice(pos).join(''), emotes); } function uhrzeit(ts) { var d = ts ? new Date(ts) : new Date(); if (isNaN(d.getTime())) d = new Date(); return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } function chatSichtbar() { return geteilt.matches || $('tab-chat').classList.contains('an'); } function nachUnten() { chatListe.scrollTop = chatListe.scrollHeight; chatKlebt = true; $('chat-runter').hidden = true; } function chatGesehen() { $('nav-chat-punkt').hidden = true; nachUnten(); } function chatAnhaengen(zeile, still) { chatListe.appendChild(zeile); while (chatListe.children.length > CHAT_MAX) chatListe.firstChild.remove(); $('chat-leer').hidden = true; if (chatKlebt) nachUnten(); else $('chat-runter').hidden = false; if (!still && !chatSichtbar()) $('nav-chat-punkt').hidden = false; } function chatNachricht(ev, still) { if (!ev || !ev.id) return; if (chatListe.querySelector('[data-id="' + CSS.escape(ev.id) + '"]')) return; var z = el('div', 'cz' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : '')); z.dataset.id = ev.id; z.dataset.user = ev.userId || ''; z.appendChild(el('span', 'zeit', uhrzeit(ev.ts))); (ev.badges || []).forEach(function (b) { // Nur Twitchs eigene Bildadressen, wie in chat.html. if (!b || !/^https:\/\/static-cdn\.jtvnw\.net\//.test(b.url)) return; var img = el('img', 'abz'); img.src = b.url; img.alt = b.title || ''; img.title = b.title || ''; z.appendChild(img); }); var wer = el('span', 'wer', ev.name || ev.login || '?'); wer.style.color = lesbar(ev.color); z.appendChild(wer); z.appendChild(document.createTextNode(': ')); var text = el('span', 'text'); textKnoten(text, String(ev.text || ''), ev.gifs, ev.emotes); z.appendChild(text); chatAnhaengen(z, still); } /** Was im Stream passiert, als eigene Zeile zwischen den Nachrichten. * Erstchatter und Bits fehlen bewusst: die zeigt schon die Nachricht. */ function alarmText(a) { var n = a.name || 'Jemand'; switch (a.type) { case 'sub': return n + ' hat abonniert' + (a.tier ? ' (Stufe ' + String(a.tier).charAt(0) + ')' : ''); case 'resub': return n + ' abonniert weiter' + (a.months ? ', seit ' + a.months + ' Monaten' : ''); case 'subgift': return n + ' verschenkt ein Abo' + (a.recipient ? ' an ' + a.recipient : ''); case 'massgift': return n + ' verschenkt ' + (a.amount || 'mehrere') + ' Abos'; case 'raid': return 'Raid von ' + n + (a.amount ? ' mit ' + a.amount + ' Zuschauern' : ''); case 'follow': return n + ' folgt jetzt'; case 'reward': return n + ' löst „' + (a.reward || 'Belohnung') + '“ ein'; default: return null; } } function chatAlarm(a) { var text = alarmText(a); if (!text) return; var z = el('div', 'cz system'); z.appendChild(el('span', 'zeit', uhrzeit())); z.appendChild(document.createTextNode(text + (a.message ? ': ' + a.message : ''))); chatAnhaengen(z); } /** Von einem Mod geloescht: durchstreichen statt verschwinden lassen, * damit man am Pult sieht, dass da etwas war. */ function chatLoeschen(ev) { var zeilen = chatListe.querySelectorAll('.cz:not(.system)'); for (var i = 0; i < zeilen.length; i++) { var z = zeilen[i]; if ((!ev.messageId && !ev.userId) || (ev.messageId && z.dataset.id === ev.messageId) || (ev.userId && z.dataset.user === ev.userId)) z.classList.add('weg'); } } chatListe.addEventListener('scroll', function () { chatKlebt = chatListe.scrollHeight - chatListe.scrollTop - chatListe.clientHeight < 40; if (chatKlebt) $('chat-runter').hidden = true; }, { passive: true }); // Emotes und GIFs laden nach und machen die Zeile hoeher. Ohne das // rutscht die neueste Nachricht unter den Rand. chatListe.addEventListener('load', function () { if (chatKlebt) nachUnten(); }, true); $('chat-runter').addEventListener('click', function () { tippen(); nachUnten(); }); geteilt.addEventListener('change', function () { if (geteilt.matches && $('tab-chat').classList.contains('an')) zeigeReiter('szenen'); if (geteilt.matches) chatGesehen(); }); // ------------------------------------------------------------ Test function baueTest(knoepfe, demoLaeuft) { var gruppen = $('test-gruppen'); gruppen.textContent = ''; var namen = []; knoepfe.forEach(function (k) { if (namen.indexOf(k.gruppe) === -1) namen.push(k.gruppe); }); namen.forEach(function (g) { var box = el('div', 'gruppe'); box.appendChild(el('h3', 'abschnitt', g)); var reihe = el('div', 'knoepfe'); knoepfe.filter(function (k) { return k.gruppe === g; }).forEach(function (k) { var b = el('button', 'knopf', k.label); b.type = 'button'; b.dataset.art = k.art; if (k.nurOffline) b.dataset.offline = '1'; reihe.appendChild(b); }); box.appendChild(reihe); gruppen.appendChild(box); }); setzeDemo(demoLaeuft); } function setzeDemo(laeuft) { var d = $('demo'); d.dataset.art = laeuft ? 'demo-aus' : 'demo-an'; d.textContent = laeuft ? 'Vorführung stoppen' : 'Vorführung starten'; d.disabled = !laeuft && zustand.live; } function testRuf(knopf) { tippen(); mitWarten(knopf, rufen('/test', { art: knopf.dataset.art })).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); if (typeof r.demo === 'boolean') setzeDemo(r.demo); }); } $('test-gruppen').addEventListener('click', function (e) { var b = e.target.closest('button[data-art]'); if (b && !b.disabled) testRuf(b); }); $('demo').addEventListener('click', function () { testRuf($('demo')); }); // ------------------------------------------------------------ Strom var quelle = null; var zuletzt = Date.now(); function verbinde() { if (quelle) { try { quelle.close(); } catch (e) { /* zu */ } } quelle = new EventSource('/events?quelle=pult'); zuletzt = Date.now(); quelle.onopen = function () { setzeNetz(true); }; quelle.onerror = function () { setzeNetz(false); // EventSource sagt nicht, ob es an der Anmeldung liegt. Eine kurze // Frage klaert das, sonst stuende "keine Verbindung" fuer immer da. holen('/soundboard/liste').then(function () { /* nur Netz */ }, function () { /* abgemeldet() hat schon */ }); }; quelle.onmessage = function (e) { zuletzt = Date.now(); setzeNetz(true); var ev; try { ev = JSON.parse(e.data); } catch (err) { return; } switch (ev.kind) { case 'state': zeigeStream(ev.stream); if (ev.obs) zeigeObs(ev.obs); if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze); // Nach dem Neuverbinden: was fehlt, nachtragen. Schon Gezeigtes // bleibt stehen, sonst waere der Rueckblick bei jedem Abriss weg. (ev.chat || []).forEach(function (m) { chatNachricht(m, true); }); break; case 'chat': chatNachricht(ev, false); break; case 'alert': chatAlarm(ev); break; case 'clear': chatLoeschen(ev); break; case 'stream': zeigeStream(ev); break; case 'obs': zeigeObs(ev); break; case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break; case 'sound': knall(ev.id); break; case 'sounds-geaendert': ladeSounds(); break; case 'tts': ladeWartend(); break; case 'fern-anfrage': ladeAnfragen(); break; } }; } function setzeNetz(an) { $('netz').classList.toggle('an', an); var banner = $('banner'); banner.hidden = an; if (!an) banner.textContent = 'Keine Verbindung zum Hub – verbinde neu …'; } // Wachhund wie in client.js: der Hub schickt alle 15 s einen Herzschlag. // Am Handy reisst die Leitung beim Wegwischen ab, ohne dass es jemand merkt. setInterval(function () { if (Date.now() - zuletzt > 45000) verbinde(); }, 10000); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && Date.now() - zuletzt > 20000) verbinde(); }); // ------------------------------------------------------------ Start holen('/pult/knoepfe').then(function (b) { baueTest(b.knoepfe || [], !!b.demo); zustand.ttsAn = !!b.tts; $('tts-karte').hidden = !b.tts; if (b.liegestuetze) zeigeLz(b.liegestuetze); ladeWartend(); }, function () { /* abgemeldet oder weg - zeigt sich oben */ }); ladeSounds(); ladeAnfragen(); verbinde(); wachHolen(); // Die Liste der Wartenden kommt nicht ueber den Strom (dort steht nur, was // vorgelesen wird). Also nachsehen, solange das Pult offen ist. setInterval(function () { if (document.visibilityState === 'visible') ladeWartend(); }, 4000); // Ob die Sound-Quelle in OBS offen ist, aendert sich ohne Ereignis. setInterval(function () { if (document.visibilityState === 'visible') ladeSounds(); }, 20000); // Ein Reiter in der Adresse (Verknuepfung "Szenen" am App-Symbol) geht vor // dem zuletzt benutzten. var gemerkt = location.hash.slice(1) || null; if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } } // Am Tablet ist der naheliegende Anfang Szenen neben dem Chat. if (!gemerkt && geteilt.matches) gemerkt = 'szenen'; if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(function (e) { console.warn('[pult] Service Worker nicht registriert', e); }); } })();