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:
1 parent
3b606e36fb
commit
bc2c8f6cbe
24 files changed
+3221
-8
No files matched your search
@@ -0,0 +1,222 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user