Files
abdulive/apps/overlay-web/public/chat.html
T
ZeasyAlexandClaude Opus 5.5 67ca472317 Chat-Overlay: echte Twitch-Abzeichen statt farbiger Punkte
Der Hub loest die Abzeichen aus dem IRC-Tag ueber Helix (global und
Kanal) zu Bildern auf und schickt sie mit jeder Chatzeile mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:12:37 +01:00

198 lines
6.7 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Chat</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Von unten nach oben, wie im Chat selbst: die neueste Nachricht steht
* unten, wo das Auge sie erwartet. Deshalb column-reverse und nicht ein
* Scrollcontainer - der haette einen Balken und wuerde mitwandern. */
#liste {
position: fixed;
inset: 0;
padding: 12px 14px;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
gap: 8px;
overflow: hidden;
}
.zeile {
padding: 9px 14px 10px;
border-radius: 12px;
background: rgba(18, 14, 29, .66);
backdrop-filter: blur(10px);
box-shadow: 0 6px 20px rgba(0, 0, 0, .45);
font-size: 19px;
line-height: 1.38;
word-break: break-word;
animation: herein .42s cubic-bezier(.16, 1.1, .3, 1) both;
transition: opacity .6s, transform .6s;
}
@keyframes herein {
from { opacity: 0; transform: translateX(-26px) scale(.97); }
to { opacity: 1; transform: none; }
}
.zeile.geht { opacity: 0; transform: translateX(-20px); }
.name { font-weight: 700; margin-right: .45em; }
.text .emote { height: 1.55em; vertical-align: -.4em; margin: 0 .05em; }
/* Ein GIF ist kein Emote: es steht fuer sich, in eigener Zeile, und wird
* begrenzt. Ohne Deckel schiebt ein 500 Pixel breites GIF die ganze Spalte
* auf - in einer 460 Pixel schmalen Browser-Source faellt das sofort auf. */
.text .gif {
display: block;
margin: 6px 0 2px;
max-width: 100%;
max-height: 180px;
width: auto;
border-radius: 8px;
background: rgba(0, 0, 0, .35);
}
/* Twitchs eigene Abzeichen. Die Bilder loest der Hub ueber Helix auf und
* schickt sie mit jeder Zeile mit (#twitch/badges.ts). */
.abzeichen {
height: 1.05em;
width: auto;
margin-right: .3em;
vertical-align: -.17em;
}
.abzeichen:last-of-type { margin-right: .45em; }
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in
* dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
* tragen das jetzt der Farbverlauf und die Zeile darueber allein, deshalb ist
* der Verlauf kraeftiger als vorher. */
.zeile.erstmals {
background: linear-gradient(90deg, rgba(74, 222, 128, .24), rgba(18, 14, 29, .66) 65%);
}
.zeile.erstmals::before {
content: "zum ersten Mal hier";
display: block;
font-size: 12px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--gruen);
margin-bottom: 3px;
}
.zeile.bits { background: linear-gradient(90deg, rgba(255, 197, 61, .22), rgba(18, 14, 29, .66) 65%); }
.zeile.bits .name { color: var(--gold) !important; }
</style>
</head>
<body>
<div id="liste"></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var liste = document.getElementById('liste');
var maximal = Overlay.param('max', 14);
var lebensdauer = Overlay.param('sekunden', 120) * 1000;
var befehleZeigen = Overlay.param('befehle', false);
/* Twitchs Farben werden auf dunklem Grund teils unlesbar - dunkelblau auf
* Schwarz ist nicht zu entziffern. Deshalb wird die Helligkeit angehoben
* statt die Farbe zu verwerfen: die Farbe ist Wiedererkennung. */
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);
var an = function (v) { return Math.min(255, Math.round(v * f)); };
return '#' + [an(r), an(g), an(b)].map(function (v) {
return v.toString(16).padStart(2, '0');
}).join('');
}
function entferne(el) {
// Doppelt aufrufbar: das Zeitlimit und das Nachruecken koennen dieselbe
// Zeile treffen.
if (!el || !el.parentNode || el.classList.contains('geht')) return;
el.classList.add('geht');
setTimeout(function () { el.remove(); }, 620);
}
/**
* Zeilen, die ueber die Obergrenze hinausgehen, ausblenden.
*
* Gezaehlt wird nur, was noch nicht auf dem Weg nach draussen ist: eine
* ausblendende Zeile haengt noch eine halbe Sekunde im DOM. Wer stattdessen
* `liste.children.length` zaehlt und in einer while-Schleife entfernt,
* bekommt eine Endlosschleife - die Laenge sinkt ja erst nach der
* Animation. In OBS friert die Quelle damit beim vierzehnten Chatbeitrag
* ein, und im Browser sieht man einen haengenden Tab.
*/
function aufraeumen() {
var bleibend = [];
var zeilen = liste.children;
for (var i = 0; i < zeilen.length; i++) {
if (!zeilen[i].classList.contains('geht')) bleibend.push(zeilen[i]);
}
// index 0 ist die neueste Zeile, also faellt das Ende zuerst.
for (var j = maximal; j < bleibend.length; j++) entferne(bleibend[j]);
}
function zeige(ev) {
if (!befehleZeigen && ev.text.charAt(0) === '!') return;
var el = document.createElement('div');
el.className = 'zeile' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : '');
el.dataset.id = ev.id;
el.dataset.user = ev.userId;
var abzeichen = '';
var bilder = ev.badges || [];
for (var i = 0; i < bilder.length; i++) {
// Nur Twitchs eigene Bildadressen: was hier steht, laedt OBS.
if (!/^https:\/\/static-cdn\.jtvnw\.net\//.test(bilder[i].url)) continue;
abzeichen += '<img class="abzeichen" src="' + Overlay.esc(bilder[i].url) +
'" alt="' + Overlay.esc(bilder[i].title) + '">';
}
el.innerHTML =
abzeichen +
'<span class="name" style="color:' + lesbar(ev.color) + '">' +
Overlay.esc(ev.name) + '</span>' +
'<span class="text">' + Overlay.nachrichtHtml(ev.text, ev.gifs, ev.emotes) + '</span>';
// prepend, weil column-reverse die Liste ohnehin umdreht.
liste.insertBefore(el, liste.firstChild);
aufraeumen();
if (lebensdauer > 0) setTimeout(function () { entferne(el); }, lebensdauer);
}
Overlay.on('chat', zeige);
// Loeschen durch einen Mod muss durchschlagen: eine geloeschte Beleidigung,
// die noch zwei Minuten im Bild steht, steht auch in der Aufzeichnung.
Overlay.on('clear', function (ev) {
var alle = Array.prototype.slice.call(liste.children);
for (var i = 0; i < alle.length; i++) {
var el = alle[i];
if (!ev.messageId && !ev.userId) entferne(el);
else if (ev.messageId && el.dataset.id === ev.messageId) entferne(el);
else if (ev.userId && el.dataset.user === ev.userId) entferne(el);
}
});
// Nach dem Neuverbinden den letzten Stand zeigen, damit die Quelle nicht
// leer im Bild steht, bis jemand schreibt.
Overlay.on('state', function (ev) {
liste.innerHTML = '';
var letzte = ev.chat.slice(-maximal);
for (var i = 0; i < letzte.length; i++) zeige(letzte[i]);
});
})();
</script>
</body>
</html>