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