Pult: Chat nur lesend, am Tablet Chat neben dem Reiter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-27 19:16:04 +01:00
1 parent 9c64309c51
commit 227ecfdcfe
6 files changed
+295 -4

No files matched your search

+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "abdulive Pult",
"short_name": "Pult",
"description": "Soundboard, Szenen, Liegestütze und Vorlesen für den Stream von abdulive_.",
"description": "Soundboard, Szenen, Chat, Liegestütze und Vorlesen für den Stream von abdulive_.",
"lang": "de",
"id": "/pult",
"start_url": "/pult",
@@ -16,6 +16,7 @@
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] },
{ "name": "Chat", "url": "/pult#chat", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
]
}
+75 -1
View File
@@ -327,11 +327,85 @@ main {
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
/* ------------------------------------------------------------ Chat */
/* Ein fester Kasten zwischen Kopf und Reitern, damit nur die Liste
* scrollt und nicht die Seite: wer im Chat hochscrollt, soll den Kopf mit
* LIVE-Anzeige und Verbindungspunkt weiter sehen. */
.tab.chat.an { display: flex; }
.chat {
position: fixed; z-index: 10; left: 0; right: 0;
top: calc(var(--kopf-h) + var(--oben));
bottom: calc(var(--reiter-h) + var(--unten));
flex-direction: column;
background: var(--grund);
}
.chat-kopf {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
padding: 14px 16px 10px; border-bottom: 1px solid var(--linie);
}
.chat-kopf .abschnitt, .chat-kopf .klein { margin: 0; }
/* Wenige Nachrichten stehen unten wie in jedem Chat, nicht oben unter dem
* Kopf. margin-top: auto statt justify-content: flex-end - mit letzterem
* laesst sich in manchen Browsern nicht mehr nach oben scrollen. */
.chat-liste {
display: flex; flex-direction: column;
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding: 8px 10px 14px; font-size: 15px; line-height: 1.42;
}
.chat-leer { position: absolute; top: 50%; left: 0; right: 0; text-align: center;
color: var(--leise); font-size: 14px; margin: 0; }
.cz { flex: none; padding: 5px 8px; border-radius: 8px; overflow-wrap: anywhere; }
.cz:first-child { margin-top: auto; }
.cz .zeit { color: var(--leise); font-size: 12px; font-variant-numeric: tabular-nums; margin-right: 6px; }
.cz .abz { height: 18px; width: auto; vertical-align: -3px; margin-right: 3px; }
.cz .wer { font-weight: 700; }
.cz .emote { height: 28px; width: auto; vertical-align: -8px; }
.cz .gif { display: block; max-width: min(100%, 220px); max-height: 140px; border-radius: 6px; margin: 4px 0 2px; }
.cz.erstmals { background: rgba(74, 222, 128, .1); box-shadow: inset 3px 0 0 var(--gruen); }
.cz.erstmals::before {
content: "Zum ersten Mal hier"; display: block; margin-bottom: 1px;
font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--gruen);
}
.cz.bits { background: rgba(255, 197, 61, .1); }
.cz.weg { opacity: .45; }
.cz.weg .text { text-decoration: line-through; }
.cz.system {
margin: 6px 0; font-size: 13px; color: var(--schrift);
background: rgba(169, 112, 255, .1); border: 1px solid rgba(169, 112, 255, .35);
}
.chat-runter {
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
padding: 9px 16px; border-radius: 999px; cursor: pointer;
font: 600 14px/1 var(--font); color: #fff;
background: var(--lila-tief); border: 1px solid var(--lila);
box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
}
/* Ab Tablet-Groesse: Chat fest rechts, links der gewaehlte Reiter. So
* stehen Szenen und Chat auf einem Bildschirm. Die Hoehe haelt quer
* gehaltene Handys heraus, dort bliebe fuer beides zu wenig. Muss in
* pult.js (`geteilt`) gleich lauten. */
@media (min-width: 760px) and (min-height: 500px) {
:root { --chat-b: clamp(300px, 36vw, 440px); }
main { max-width: none; margin: 0; padding-right: calc(var(--chat-b) + 16px); }
.tab.chat {
display: flex; left: auto; width: var(--chat-b); bottom: 0;
padding-bottom: var(--unten); border-left: 1px solid var(--linie); animation: none;
}
.chat-runter { bottom: calc(14px + var(--unten)); }
.reiter { right: var(--chat-b); }
#nav-chat { display: none; }
.meldung { left: calc((100% - var(--chat-b)) / 2); }
}
/* ------------------------------------------------------------ Reiter */
.reiter {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: grid; grid-template-columns: repeat(4, 1fr);
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--reiter-h) + var(--unten));
padding-bottom: var(--unten);
background: rgba(18, 14, 29, .94);
+17
View File
@@ -131,6 +131,18 @@
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
</section>
<!-- ======================================================== Chat
Am Handy ein Reiter, ab Tablet-Breite steht er rechts daneben. -->
<section id="tab-chat" class="tab chat" aria-labelledby="nav-chat">
<div class="chat-kopf">
<h3 class="abschnitt">Chat</h3>
<span class="klein">nur lesen</span>
</div>
<div id="chat-liste" class="chat-liste" role="log" aria-live="off"></div>
<p id="chat-leer" class="chat-leer">Noch keine Nachrichten.</p>
<button id="chat-runter" class="chat-runter" type="button" hidden>Neue Nachrichten ↓</button>
</section>
</main>
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
@@ -150,6 +162,11 @@
Stream
<i id="nav-tts" class="punkt" hidden></i>
</button>
<button id="nav-chat" data-tab="chat" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/></svg>
Chat
<i id="nav-chat-punkt" class="punkt" hidden></i>
</button>
<button id="nav-test" data-tab="test" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
Test
+183 -1
View File
@@ -1,4 +1,5 @@
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy.
/* 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
@@ -12,6 +13,8 @@
'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 = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
@@ -86,6 +89,9 @@
// ------------------------------------------------------------ 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);
@@ -94,6 +100,7 @@
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) {
@@ -558,6 +565,173 @@
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) {
@@ -628,7 +802,13 @@
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;
@@ -684,6 +864,8 @@
// 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) {