Files
ZeasyAlexandClaude Opus 5 bc2c8f6cbe Overlay: fuenf Browser-Sources fuer OBS
Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Build, kein
Framework - OBS laedt die Dateien direkt, und jede Zwischenstufe waere ein
Ding, das mitten im Stream kaputtgehen kann.

Server-Sent Events statt WebSocket, entgegen der Skizze in chat-control.md:
Node bringt einen WebSocket-*Client* mit, aber keinen Server, `EventSource`
verbindet sich von allein neu, und die Richtung stimmt - das Overlay hoert
nur zu. Gegen die tote Browser-Source nach einem Neustart stehen drei Dinge:
der eingebaute Reconnect, ein Herzschlag als echtes Ereignis (ein
SSE-Kommentar haelt die Verbindung offen, ist im Browser aber unsichtbar),
und ein Zustands-Schnappschuss beim Verbinden.

Abos, Raids, Bits und Loeschungen kommen ueber IRC-USERNOTICE und CLEARCHAT
herein; das wurde bisher weggeworfen. Follows fehlen weiter, die gibt es nur
ueber EventSub.

GIFs im Chat: Twitch schickt dafuer einen Tag `gifs`, den wir nicht kannten.

  gifs=0-22|Pnd7qLFuGCOntZcNKB|https://media0.giphy.com/media/.../giphy.gif
  PRIVMSG #abdulive_ :[Idk GIF by Respective]

Der Nachrichtentext ist nur ein Platzhalter, der Bereich markiert ihn.
Ausgewertet wird er jetzt; ohne das stand woertlich "[Idk GIF by Respective]"
im Bild. Gefunden wurde der Tag durch einen Rohmitschnitt - in 3094
Nachrichten aus acht grossen Kanaelen war kein einziges GIF, weil die Adresse
eben nicht im Text steht.

Zwei Fehler, die beim Durchklicken im Browser aufgefallen sind:

- Die Aufraeumfunktion im Chat zaehlte Zeilen, die erst nach der
  Ausblend-Animation aus dem DOM verschwinden, und lief in eine
  Endlosschleife. In OBS waere die Quelle beim vierzehnten Chatbeitrag
  eingefroren.
- Der Bereichsparser der GIFs war zu nachsichtig: `-1-4` zerfaellt beim
  Trennen in ['', '1', '4'], und `Number('')` ist 0 - der Bereich saehe
  gueltig aus und wuerde den falschen Textteil ersetzen.

Auf der Uebersichtsseite steht fuer jede Einblendung, jede Chat-Variante und
jeden Effekt ein Knopf. Ohne den liesse sich eine Raid-Einblendung nur
pruefen, indem man auf einen echten Raid wartet. Was einen Zustand setzt, ist
waehrend eines Streams gesperrt; "Stand zuruecksetzen" ausdruecklich nicht,
sonst kaeme man aus einem erfundenen Zustand nicht mehr heraus.

build-package.ts nimmt jetzt auch html/css/js mit - sonst liefert das Paket
404 statt Overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:28 +02:00

223 lines
7.4 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Einblendungen</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Einblendungen laufen nacheinander, nie uebereinander. Zwei Alarme
* gleichzeitig sind unlesbar, und bei einem Massengeschenk kommen zehn
* Stueck in derselben Sekunde. */
.buehne {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.karte {
position: relative;
overflow: hidden;
min-width: 620px;
max-width: 1100px;
padding: 34px 54px 38px;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0 34px;
border-radius: 26px;
background: var(--tinte-klar);
backdrop-filter: blur(18px) saturate(150%);
border: 1px solid rgba(255, 255, 255, .14);
box-shadow: var(--schatten), var(--glanz), 0 0 90px -20px var(--ton);
/* Die Kante in der Akzentfarbe ist das, was die Einblendung dem Ereignis
* zuordnet, bevor man ein Wort gelesen hat. */
border-left: 8px solid var(--ton);
opacity: 0;
}
.karte.an { animation: herein .75s cubic-bezier(.16, 1.2, .3, 1) both; }
.karte.weg { animation: hinaus .45s cubic-bezier(.5, 0, .75, 0) both; }
@keyframes herein {
from { opacity: 0; transform: translateY(46px) scale(.9); filter: blur(8px); }
to { opacity: 1; transform: none; filter: none; }
}
@keyframes hinaus {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(-34px) scale(.96); }
}
.zeichen {
font-size: 92px;
line-height: 1;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .6));
animation: huepfen .9s cubic-bezier(.2, 1.6, .4, 1) .12s both;
}
@keyframes huepfen {
0% { transform: scale(0) rotate(-25deg); }
70% { transform: scale(1.18) rotate(6deg); }
100% { transform: scale(1) rotate(0); }
}
.kopf {
font-size: 22px;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ton);
margin-bottom: 4px;
}
.name {
font-size: 58px;
font-weight: 700;
line-height: 1.05;
letter-spacing: -.02em;
}
.zusatz { font-size: 26px; color: var(--schrift-leise); margin-top: 6px; }
.notiz {
margin-top: 14px;
font-size: 22px;
color: var(--schrift-leise);
max-width: 44ch;
/* Zuschauertext. Drei Zeilen, dann Schluss - sonst schiebt jemand mit
* einem Roman die Karte aus dem Bild. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notiz .emote { height: 1.4em; vertical-align: -.3em; }
/* Der Balken, der unter der Karte ablaeuft: zeigt, wie lange sie noch steht. */
.uhr {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 4px;
background: var(--ton);
transform-origin: left;
animation: ablaufen var(--dauer) linear both;
opacity: .8;
}
@keyframes ablaufen { from { transform: scaleX(1); } to { transform: scaleX(0); } }
</style>
</head>
<body>
<div class="buehne"><div id="platz"></div></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var platz = document.getElementById('platz');
var dauer = Overlay.param('sekunden', 7) * 1000;
var warteschlange = [];
var laeuft = false;
/* Ein Eintrag je Art: Farbe, Zeichen, Ueberschrift und die beiden
* Textzeilen. Alles an einem Ort, damit eine neue Art eine Zeile ist. */
var ARTEN = {
sub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Neues Abo' },
resub: { ton: 'var(--lila)', zeichen: '💜', kopf: 'Abo verlängert' },
subgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abo verschenkt' },
massgift: { ton: 'var(--pink)', zeichen: '🎁', kopf: 'Abos verschenkt' },
raid: { ton: 'var(--tuerkis)', zeichen: '🚀', kopf: 'Raid' },
cheer: { ton: 'var(--gold)', zeichen: '✨', kopf: 'Bits' },
follow: { ton: 'var(--gruen)', zeichen: '⭐', kopf: 'Neuer Follow' },
first: { ton: 'var(--gruen)', zeichen: '👋', kopf: 'Zum ersten Mal hier' },
clip: { ton: 'var(--tuerkis)', zeichen: '🎬', kopf: 'Clip gespeichert' },
// Eigene Art, eigene Farbe, eigene Ueberschrift. Eine eingeloeste
// Belohnung darf nicht aussehen wie ein Abo.
reward: { ton: 'var(--gold)', zeichen: '🎟️', kopf: 'Belohnung eingelöst' },
};
/** Die zweite Zeile. Deutsch mit richtigen Pluralformen - "1 Monate"
* liest sich wie ein Fehler, und das ist es auch. */
function zusatz(ev) {
switch (ev.type) {
case 'resub':
return ev.months ? ev.months + (ev.months === 1 ? ' Monat' : ' Monate') + ' dabei' : '';
case 'subgift':
return ev.recipient ? 'an ' + Overlay.esc(ev.recipient) : '';
case 'massgift':
return (ev.amount || 1) + (ev.amount === 1 ? ' Abo' : ' Abos') + ' an den Chat';
case 'raid':
return 'mit ' + Overlay.zahl(ev.amount || 0) +
((ev.amount === 1) ? ' Zuschauer' : ' Zuschauern');
case 'cheer':
return Overlay.zahl(ev.amount || 0) + ' Bits';
case 'reward':
// Der Titel der Belohnung ist die Information, nicht die Punktzahl.
return Overlay.esc(ev.reward || '') +
(ev.amount ? ' · ' + Overlay.zahl(ev.amount) + ' Punkte' : '');
default:
return '';
}
}
function zeige(ev) {
var art = ARTEN[ev.type];
if (!art) return naechste();
var karte = document.createElement('div');
karte.className = 'karte an';
karte.style.setProperty('--ton', art.ton);
karte.style.setProperty('--dauer', (dauer / 1000) + 's');
var text = zusatz(ev);
// Die Nachricht nur dort, wo Zuschauer wirklich eine schreiben.
var notiz = (ev.message && (ev.type === 'resub' || ev.type === 'cheer' ||
ev.type === 'sub' || ev.type === 'reward'))
? '<div class="notiz">' + Overlay.emoteHtml(ev.message, []) + '</div>' : '';
karte.innerHTML =
'<div class="zeichen">' + art.zeichen + '</div>' +
'<div>' +
'<div class="kopf">' + art.kopf + '</div>' +
'<div class="name kontur">' + Overlay.esc(ev.name || 'Jemand') + '</div>' +
(text ? '<div class="zusatz">' + text + '</div>' : '') +
notiz +
'</div>' +
'<div class="uhr"></div>';
// Der Lichtstreifen liegt auf der Karte, nicht im Markup - so laeuft er
// genau einmal und stoert das Raster nicht.
karte.classList.add('streifen');
platz.appendChild(karte);
setTimeout(function () {
karte.classList.remove('an');
karte.classList.add('weg');
setTimeout(function () { karte.remove(); naechste(); }, 460);
}, dauer);
}
function naechste() {
var ev = warteschlange.shift();
if (!ev) { laeuft = false; return; }
laeuft = true;
zeige(ev);
}
Overlay.on('alert', function (ev) {
// Erstchatter sind haeufig und sollen den Bildschirm nicht sprengen -
// wer sie nicht will, haengt ?erstchatter=0 an die Adresse.
if (ev.type === 'first' && !Overlay.param('erstchatter', true)) return;
warteschlange.push(ev);
// Eine Warteschlange, die bei einem Bot-Angriff auf 200 waechst, laeuft
// eine halbe Stunde lang durch. Lieber die aeltesten fallen lassen.
if (warteschlange.length > 12) warteschlange.splice(0, warteschlange.length - 12);
if (!laeuft) naechste();
});
// Nach dem Neuverbinden NICHT die alten Alarme nachspielen: ein Abo von
// vor zehn Minuten noch einmal einzublenden ist schlimmer als es zu
// verpassen. Der Schnappschuss wird hier bewusst ignoriert.
Overlay.on('state', function () { });
})();
</script>
</body>
</html>