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>
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-18 00:13:28 +02:00
1 parent 3b606e36fb
commit bc2c8f6cbe
24 files changed
+3221 -8

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Statusleiste</title>
<link rel="stylesheet" href="/theme.css">
<style>
#leiste {
position: fixed;
left: 28px; right: 28px; top: 22px;
height: 92px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 28px;
padding: 0 30px;
border-radius: 20px;
background: var(--tinte-klar);
backdrop-filter: blur(16px) saturate(150%);
border: 1px solid rgba(255, 255, 255, .1);
box-shadow: var(--schatten), var(--glanz);
/* Ein Farbverlauf an der Oberkante statt eines Rahmens: er bindet die
* Leiste an die Marke, ohne einen Kasten um alles zu ziehen. */
overflow: hidden;
}
#leiste::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 3px;
background: linear-gradient(90deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
background-size: 300% 100%;
animation: wandern 9s linear infinite;
}
@keyframes wandern { to { background-position: 300% 0; } }
.marke { display: flex; align-items: center; gap: 12px; }
.punkt {
width: 13px; height: 13px;
border-radius: 50%;
background: var(--rot);
box-shadow: 0 0 0 0 rgba(239, 68, 68, .6);
}
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
body:not(.live) .punkt { background: #555; }
@keyframes klopfen {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
70% { box-shadow: 0 0 0 16px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.kanal { font-size: 26px; font-weight: 700; letter-spacing: -.01em; }
.mitte { min-width: 0; }
#titel {
font-size: 25px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#kategorie { font-size: 17px; color: var(--schrift-leise); margin-top: 2px; }
.werte { display: flex; gap: 30px; }
.wert { text-align: right; min-width: 92px; }
.wert .zahl {
font-size: 30px; font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
display: block;
}
.wert .beschriftung {
font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
color: var(--schrift-leise);
}
/* Kurzes Aufleuchten, wenn sich eine Zahl aendert. Ohne das uebersieht man
* jede Veraenderung, weil die Leiste sonst voellig still steht. */
.zahl.neu { animation: aufleuchten .8s ease-out; }
@keyframes aufleuchten {
0% { color: var(--tuerkis); transform: translateY(-3px); }
100% { color: inherit; transform: none; }
}
</style>
</head>
<body>
<div id="leiste">
<div class="marke">
<span class="punkt"></span>
<span class="kanal kontur" id="kanal">abdulive_</span>
</div>
<div class="mitte">
<div id="titel" class="kontur">nicht live</div>
<div id="kategorie"></div>
</div>
<div class="werte">
<div class="wert"><span class="zahl" id="zuschauer">-</span>
<span class="beschriftung">Zuschauer</span></div>
<div class="wert"><span class="zahl" id="laufzeit">-</span>
<span class="beschriftung">live seit</span></div>
</div>
</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var titel = document.getElementById('titel');
var kategorie = document.getElementById('kategorie');
var zuschauer = document.getElementById('zuschauer');
var laufzeit = document.getElementById('laufzeit');
var kanal = document.getElementById('kanal');
var seit = null;
kanal.textContent = Overlay.param('kanal', 'abdulive_');
function setze(el, wert) {
if (el.textContent === wert) return;
el.textContent = wert;
el.classList.remove('neu');
// Neuzeichnen erzwingen, sonst startet die Animation beim zweiten Mal nicht.
void el.offsetWidth;
el.classList.add('neu');
}
function stand(ev) {
document.body.classList.toggle('live', !!ev.live);
titel.textContent = ev.live ? (ev.title || 'ohne Titel') : 'nicht live';
kategorie.textContent = ev.live ? (ev.category || '') : '';
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
seit = ev.live ? ev.startedAt : null;
tick();
}
function tick() {
laufzeit.textContent = seit ? Overlay.laufzeit(seit) : '-';
}
setInterval(tick, 10000);
Overlay.on('stream', stand);
Overlay.on('state', function (ev) { stand(ev.stream); });
})();
</script>
</body>
</html>