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
+100
-2
@@ -1,5 +1,103 @@
|
||||
# @abdulive/overlay-web
|
||||
|
||||
Browser-Sources fuer OBS: Alerts, Effekte, Community-Hinweis, Umfrage, Raid-Panel.
|
||||
Die Browser-Sources für OBS. Fünf Seiten, alle mit durchsichtigem Hintergrund,
|
||||
alle an demselben Ereignisstrom.
|
||||
|
||||
Spezifikation: [docs/projects/chat-control.md](../../docs/projects/chat-control.md)
|
||||
Kein Build, kein Framework, keine Abhängigkeit: OBS lädt die Dateien direkt,
|
||||
und jede Zwischenstufe wäre ein Ding, das mitten im Stream kaputtgehen kann.
|
||||
Gemeinsam sind nur [`theme.css`](public/theme.css) (Farben, Schrift, Kontur)
|
||||
und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes).
|
||||
|
||||
## Die Seiten
|
||||
|
||||
| Adresse | Größe | Was sie zeigt |
|
||||
|---|---|---|
|
||||
| `/alerts` | 1920×1080 | Abos, Raids, Bits, Erstchatter. Eine nach der anderen, nie übereinander. |
|
||||
| `/chat` | 460×900 | Chatnachrichten mit Emotes, Abzeichen, Farben. |
|
||||
| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. |
|
||||
| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. |
|
||||
| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. |
|
||||
|
||||
Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`).
|
||||
|
||||
## In OBS
|
||||
|
||||
Je Seite eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe
|
||||
setzen. Zwei Häkchen sind wichtig:
|
||||
|
||||
- **"Quelle beim Unsichtbarwerden deaktivieren" aus.** Sonst verpasst das
|
||||
Overlay alles, was passiert, während eine andere Szene im Bild ist, und
|
||||
baut sich bei jedem Szenenwechsel neu auf.
|
||||
- **"Browser beim Szenenwechsel aktualisieren" aus.** Eine laufende
|
||||
Einblendung würde sonst mitten im Wechsel abgeschnitten.
|
||||
|
||||
## Einstellungen über die Adresse
|
||||
|
||||
Es gibt keine Konfigurationsdatei. Was einstellbar ist, hängt hinten an der
|
||||
URL - so lässt es sich in OBS ändern, ohne den Dienst anzufassen.
|
||||
|
||||
| Seite | Parameter | Vorgabe |
|
||||
|---|---|---|
|
||||
| alle | `probe=1` | dunkler Hintergrund zum Ansehen im Browser |
|
||||
| `/alerts` | `sekunden` | 7 - wie lange eine Einblendung steht |
|
||||
| `/alerts` | `erstchatter=0` | Erstchatter nicht einblenden |
|
||||
| `/chat` | `max` | 14 - so viele Zeilen gleichzeitig |
|
||||
| `/chat` | `sekunden` | 120 - danach verblasst eine Zeile (0 = nie) |
|
||||
| `/chat` | `befehle=1` | auch `!`-Befehle zeigen |
|
||||
| `/status` | `kanal` | abdulive_ |
|
||||
| `/effects` | `max` | 260 - Obergrenze für Teilchen |
|
||||
| `/starting` | `text`, `unter` | "Gleich geht es los", leer |
|
||||
|
||||
## Zum Ausprobieren
|
||||
|
||||
Auf der Übersichtsseite (`/`) steht unter **Ausprobieren** für jede Einblendung,
|
||||
jede Chat-Variante und jeden Effekt ein Knopf. Er löst ein erfundenes Ereignis
|
||||
aus, das nur an die Browser-Sources geht - nichts davon landet in der Datenbank,
|
||||
auf Discord oder bei Twitch.
|
||||
|
||||
Am besten die zu prüfende Seite über „ansehen" in einem zweiten Fenster offen
|
||||
haben. Dabei hilft `?probe=1`: das legt einen dunklen Grund unter die sonst
|
||||
durchsichtige Seite.
|
||||
|
||||
Zwei Dinge sind gesperrt, solange ein Stream läuft: „Stream läuft" und die
|
||||
Vorführung. Beide setzen einen Zustand, der in der Statusleiste stehen bliebe,
|
||||
bis die nächste Twitch-Abfrage ihn geraderückt. „Stand zurücksetzen" ist
|
||||
absichtlich nie gesperrt - das ist der Weg aus einem erfundenen Zustand wieder
|
||||
heraus.
|
||||
|
||||
Dieselben Ereignisse im Dauerbetrieb, auch ohne laufenden Dienst:
|
||||
|
||||
```
|
||||
abdulive overlay --demo
|
||||
```
|
||||
|
||||
Erfundener Chat, alle neun Sekunden eine Einblendung, ab und zu eine
|
||||
Chat-Spitze. Ohne das ließe sich eine Raid-Einblendung nur prüfen, indem man
|
||||
auf einen echten Raid wartet.
|
||||
|
||||
## Was fehlt
|
||||
|
||||
- **Follows.** Kommen nicht über IRC, dafür braucht es EventSub. Bis dahin
|
||||
bleibt das Overlay bei Follows stumm; Abos, Raids und Bits kommen an.
|
||||
- **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene
|
||||
Emotes werden angezeigt.
|
||||
- **Töne.** Es liegen keine Audiodateien im Repo, und ein Overlay, das
|
||||
ungefragt Krach macht, ist schlimmer als eines, das still ist.
|
||||
|
||||
## Vorlesen (`/tts`)
|
||||
|
||||
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||
|
||||
Gesprochen wird über `speechSynthesis`, die im Browser eingebaute
|
||||
Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das
|
||||
Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird
|
||||
teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet
|
||||
einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das
|
||||
auffallen und nicht stillschweigend fehlen.
|
||||
|
||||
Einstellungen: `?sprache=de-DE`, `?tempo=1`, `?laut=1`.
|
||||
|
||||
Vorgelesen wird nur, was auf der Übersichtsseite freigegeben wurde. Nach einem
|
||||
Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||
vorzulesen ist schlimmer, als sie zu verpassen.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!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);
|
||||
}
|
||||
|
||||
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten
|
||||
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */
|
||||
.abzeichen {
|
||||
display: inline-block;
|
||||
width: .5em; height: .5em;
|
||||
border-radius: 50%;
|
||||
margin-right: .4em;
|
||||
vertical-align: .08em;
|
||||
}
|
||||
.abzeichen.mod { background: var(--gruen); }
|
||||
.abzeichen.abo { background: var(--lila); }
|
||||
.abzeichen.host { background: var(--rot); }
|
||||
|
||||
/* 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 = '';
|
||||
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>';
|
||||
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>';
|
||||
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>';
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Gemeinsamer Anschluss aller Browser-Sources an den Ereignisstrom.
|
||||
*
|
||||
* Bewusst kein Modul und kein Build: OBS laedt diese Datei direkt, und jede
|
||||
* Zwischenstufe waere ein Ding, das im Stream kaputtgehen kann.
|
||||
*
|
||||
* Die drei Dinge, an denen solche Overlays sonst scheitern:
|
||||
*
|
||||
* 1. Die Quelle bleibt nach einem Neustart des Dienstes tot. -> EventSource
|
||||
* verbindet von allein neu, und ein Wachhund greift ein, wenn selbst das
|
||||
* haengt (OBS haelt tote Verbindungen erstaunlich lange fuer lebendig).
|
||||
* 2. Die Quelle ist nach dem Neuverbinden leer. -> Der Server schickt als
|
||||
* erstes einen Schnappschuss, den die Seiten als `state` bekommen.
|
||||
* 3. Chat-Text landet ungeprueft im DOM. -> `Overlay.esc` und `emoteHtml`
|
||||
* sind die einzigen Wege, auf denen fremder Text hier hineinkommt.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var params = new URLSearchParams(location.search);
|
||||
var handlers = {};
|
||||
var quelle = null;
|
||||
var letzteNachricht = Date.now();
|
||||
|
||||
/** Ohne Nachricht in dieser Zeit gilt die Verbindung als tot. Der Server
|
||||
* schickt alle 15 s einen Herzschlag, also sind 45 s drei verpasste. */
|
||||
var TOT_NACH_MS = 45000;
|
||||
|
||||
function verbinde() {
|
||||
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
|
||||
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : '');
|
||||
quelle = new EventSource(url);
|
||||
letzteNachricht = Date.now();
|
||||
|
||||
quelle.onopen = function () {
|
||||
document.body.classList.remove('offline');
|
||||
letzteNachricht = Date.now();
|
||||
};
|
||||
quelle.onerror = function () {
|
||||
document.body.classList.add('offline');
|
||||
};
|
||||
quelle.onmessage = function (e) {
|
||||
letzteNachricht = Date.now();
|
||||
document.body.classList.remove('offline');
|
||||
var ev;
|
||||
try { ev = JSON.parse(e.data); } catch (err) { return; }
|
||||
if (ev.kind === 'beat') return;
|
||||
var liste = handlers[ev.kind] || [];
|
||||
for (var i = 0; i < liste.length; i++) {
|
||||
try { liste[i](ev); } catch (err) { console.error('[overlay]', ev.kind, err); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
if (Date.now() - letzteNachricht > TOT_NACH_MS) {
|
||||
console.warn('[overlay] nichts mehr gehoert, verbinde neu');
|
||||
verbinde();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
window.Overlay = {
|
||||
/** Auf eine Ereignisart hoeren. Mehrere Hoerer je Art sind erlaubt. */
|
||||
on: function (kind, fn) {
|
||||
(handlers[kind] = handlers[kind] || []).push(fn);
|
||||
return window.Overlay;
|
||||
},
|
||||
|
||||
/** Einstellung aus der Adresse, z. B. ?sekunden=60. */
|
||||
param: function (name, fallback) {
|
||||
var v = params.get(name);
|
||||
if (v === null || v === '') return fallback;
|
||||
if (typeof fallback === 'number') {
|
||||
var n = Number(v);
|
||||
return isFinite(n) ? n : fallback;
|
||||
}
|
||||
if (typeof fallback === 'boolean') return v !== '0' && v !== 'false' && v !== 'nein';
|
||||
return v;
|
||||
},
|
||||
|
||||
/** Der einzige erlaubte Weg, fremden Text ins DOM zu bringen. */
|
||||
esc: function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Text mit eingesetzten Emotes. Erst maskieren, dann ersetzen - in der
|
||||
* anderen Reihenfolge waere jeder Emote-Name ein Einfallstor.
|
||||
*
|
||||
* Twitch liefert nur Name und ID, nicht die Position. Ersetzt wird
|
||||
* deshalb der Name als ganzes Wort. BTTV und 7TV sind nicht dabei; die
|
||||
* haengen an einer zweiten API und waeren ein eigenes Stueck Arbeit.
|
||||
*/
|
||||
emoteHtml: function (text, emotes) {
|
||||
var html = window.Overlay.esc(text);
|
||||
if (!emotes || !emotes.length) return html;
|
||||
for (var i = 0; i < emotes.length; i++) {
|
||||
var e = emotes[i];
|
||||
if (!e.id || !e.name) continue;
|
||||
var sicher = window.Overlay.esc(e.name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
var src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||
encodeURIComponent(e.id) + '/default/dark/2.0';
|
||||
html = html.replace(new RegExp('(^|\\s)' + sicher + '(?=\\s|$)', 'g'),
|
||||
'$1<img class="emote" src="' + src + '" alt="' + window.Overlay.esc(e.name) + '">');
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Nachrichtentext mit eingesetzten GIFs und Emotes.
|
||||
*
|
||||
* GIFs kommen anders als Emotes mit einem Zeichenbereich: im Text steht
|
||||
* nur ein Platzhalter wie "[Idk GIF by Respective]", und der gehoert
|
||||
* ersetzt. Wer nur den Text anzeigt, zeigt genau diese eckige Klammer.
|
||||
*/
|
||||
nachrichtHtml: function (text, gifs, emotes) {
|
||||
if (!gifs || !gifs.length) return window.Overlay.emoteHtml(text, emotes);
|
||||
|
||||
// Nach Zeichen zerlegen, nicht nach Bytes: die Bereiche zaehlen
|
||||
// Zeichen, und ein Emoji im Text wuerde die Rechnung sonst verschieben.
|
||||
var chars = Array.from(text);
|
||||
var sortiert = gifs.slice().sort(function (a, b) { return a.from - b.from; });
|
||||
var html = '';
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < sortiert.length; i++) {
|
||||
var g = sortiert[i];
|
||||
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') continue;
|
||||
if (g.from > pos) {
|
||||
html += window.Overlay.emoteHtml(chars.slice(pos, g.from).join(''), emotes);
|
||||
}
|
||||
html += '<img class="gif" src="' + window.Overlay.esc(g.url) + '" alt="GIF"' +
|
||||
' onerror="this.replaceWith(document.createTextNode(this.alt))">';
|
||||
pos = Math.max(pos, g.to + 1);
|
||||
}
|
||||
if (pos < chars.length) {
|
||||
html += window.Overlay.emoteHtml(chars.slice(pos).join(''), emotes);
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
/** "2 Std 14 Min" aus einem ISO-Zeitpunkt. */
|
||||
laufzeit: function (seit) {
|
||||
var ms = Date.now() - Date.parse(seit);
|
||||
if (!isFinite(ms) || ms < 0) return '';
|
||||
var m = Math.floor(ms / 60000);
|
||||
var h = Math.floor(m / 60);
|
||||
return h > 0 ? h + ':' + String(m % 60).padStart(2, '0') + ' Std'
|
||||
: m + ' Min';
|
||||
},
|
||||
|
||||
/** Zahl mit Tausenderpunkt, wie man sie in Deutschland liest. */
|
||||
zahl: function (n) {
|
||||
return Number(n || 0).toLocaleString('de-DE');
|
||||
},
|
||||
};
|
||||
|
||||
// Der Punkt unten rechts, der nur bei Verlust erscheint.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (params.get('probe')) document.body.classList.add('probe');
|
||||
var d = document.createElement('div');
|
||||
d.className = 'getrennt';
|
||||
d.textContent = 'keine Verbindung';
|
||||
document.body.appendChild(d);
|
||||
verbinde();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,194 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Effekte</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Ein Canvas fuer alles. Hunderte DOM-Knoten, die sich pro Bild bewegen,
|
||||
* kosten in OBS spuerbar Leistung - und Leistung, die hier draufgeht, fehlt
|
||||
* beim Encoder. */
|
||||
canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* Der kurze Lichtschein bei einer Chat-Spitze. Liegt hinter den Teilchen und
|
||||
* ist das, was den Effekt "teuer" wirken laesst. */
|
||||
#schein {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(ellipse at 50% 60%,
|
||||
rgba(169, 112, 255, .32), transparent 62%);
|
||||
}
|
||||
#schein.an { animation: blitz 1.6s ease-out both; }
|
||||
@keyframes blitz {
|
||||
0% { opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="schein"></div>
|
||||
<canvas id="bild"></canvas>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var canvas = document.getElementById('bild');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var schein = document.getElementById('schein');
|
||||
var teilchen = [];
|
||||
var bilder = {}; // Emote-ID -> Image
|
||||
var letzteEmotes = []; // was gerade im Chat benutzt wird
|
||||
var laeuft = false;
|
||||
|
||||
/** Obergrenze. Ein Raid mit 200 Leuten darf den Encoder nicht umbringen. */
|
||||
var MAX = Overlay.param('max', 260);
|
||||
|
||||
function passeAn() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
passeAn();
|
||||
window.addEventListener('resize', passeAn);
|
||||
|
||||
function bild(id) {
|
||||
if (bilder[id]) return bilder[id];
|
||||
var img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
|
||||
encodeURIComponent(id) + '/default/dark/3.0';
|
||||
bilder[id] = img;
|
||||
return img;
|
||||
}
|
||||
|
||||
/** Emote-Regen: faellt von oben, dreht sich leicht, verblasst unten. */
|
||||
function regen(anzahl) {
|
||||
var quellen = letzteEmotes.slice(-12);
|
||||
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||
var e = quellen.length ? quellen[(Math.random() * quellen.length) | 0] : null;
|
||||
teilchen.push({
|
||||
art: e ? 'emote' : 'punkt',
|
||||
img: e ? bild(e) : null,
|
||||
farbe: farbe(),
|
||||
x: Math.random() * canvas.width,
|
||||
y: -60 - Math.random() * canvas.height * 0.5,
|
||||
vx: (Math.random() - 0.5) * 0.8,
|
||||
vy: 2.2 + Math.random() * 3.4,
|
||||
g: 0.02,
|
||||
dreh: (Math.random() - 0.5) * 0.06,
|
||||
winkel: (Math.random() - 0.5) * 0.7,
|
||||
groesse: 34 + Math.random() * 30,
|
||||
leben: 1,
|
||||
zerfall: 0.0016 + Math.random() * 0.0016,
|
||||
});
|
||||
}
|
||||
starte();
|
||||
}
|
||||
|
||||
var PALETTE = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d', '#4ade80'];
|
||||
function farbe() { return PALETTE[(Math.random() * PALETTE.length) | 0]; }
|
||||
|
||||
/** Konfetti: schiesst aus der unteren Mitte nach oben und faellt zurueck. */
|
||||
function konfetti(anzahl) {
|
||||
for (var i = 0; i < anzahl && teilchen.length < MAX; i++) {
|
||||
var winkel = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
|
||||
var tempo = 9 + Math.random() * 11;
|
||||
teilchen.push({
|
||||
art: 'schnipsel',
|
||||
farbe: farbe(),
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 340,
|
||||
y: canvas.height + 14,
|
||||
vx: Math.cos(winkel) * tempo,
|
||||
vy: Math.sin(winkel) * tempo,
|
||||
g: 0.28,
|
||||
dreh: (Math.random() - 0.5) * 0.4,
|
||||
winkel: Math.random() * Math.PI,
|
||||
groesse: 9 + Math.random() * 9,
|
||||
leben: 1,
|
||||
zerfall: 0.0055 + Math.random() * 0.004,
|
||||
});
|
||||
}
|
||||
starte();
|
||||
}
|
||||
|
||||
function starte() {
|
||||
if (laeuft) return;
|
||||
laeuft = true;
|
||||
requestAnimationFrame(schritt);
|
||||
}
|
||||
|
||||
function schritt() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (var i = teilchen.length - 1; i >= 0; i--) {
|
||||
var t = teilchen[i];
|
||||
t.vy += t.g;
|
||||
t.x += t.vx;
|
||||
t.y += t.vy;
|
||||
t.winkel += t.dreh;
|
||||
t.leben -= t.zerfall;
|
||||
|
||||
if (t.leben <= 0 || t.y > canvas.height + 120) { teilchen.splice(i, 1); continue; }
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, t.leben));
|
||||
ctx.translate(t.x, t.y);
|
||||
ctx.rotate(t.winkel);
|
||||
|
||||
if (t.art === 'emote' && t.img && t.img.complete && t.img.naturalWidth) {
|
||||
ctx.drawImage(t.img, -t.groesse / 2, -t.groesse / 2, t.groesse, t.groesse);
|
||||
} else if (t.art === 'schnipsel') {
|
||||
ctx.fillStyle = t.farbe;
|
||||
// Schmales Rechteck statt Quadrat: sieht in Drehung nach Papier aus.
|
||||
ctx.fillRect(-t.groesse / 2, -t.groesse / 4, t.groesse, t.groesse / 2);
|
||||
} else {
|
||||
ctx.fillStyle = t.farbe;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, t.groesse / 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (teilchen.length) requestAnimationFrame(schritt);
|
||||
else { laeuft = false; ctx.clearRect(0, 0, canvas.width, canvas.height); }
|
||||
}
|
||||
|
||||
// ---- Ausloeser
|
||||
|
||||
Overlay.on('chat', function (ev) {
|
||||
for (var i = 0; i < ev.emotes.length; i++) {
|
||||
if (letzteEmotes.indexOf(ev.emotes[i].id) === -1) letzteEmotes.push(ev.emotes[i].id);
|
||||
}
|
||||
if (letzteEmotes.length > 24) letzteEmotes.splice(0, letzteEmotes.length - 24);
|
||||
});
|
||||
|
||||
Overlay.on('hype', function (ev) {
|
||||
schein.classList.remove('an');
|
||||
void schein.offsetWidth;
|
||||
schein.classList.add('an');
|
||||
// Je hoeher die Punktzahl, desto mehr - aber gedeckelt, sonst steht bei
|
||||
// einem echten Ausbruch das Bild voll und man sieht das Spiel nicht mehr.
|
||||
regen(Math.min(110, 34 + Math.round((ev.score || 1) * 9)));
|
||||
});
|
||||
|
||||
// Konfetti als eigenes Ereignis: welche Belohnung Konfetti ausloest,
|
||||
// entscheidet der Hub - diese Seite muss davon nichts wissen.
|
||||
Overlay.on('konfetti', function (ev) {
|
||||
konfetti(Math.round(90 * (ev.staerke || 1)));
|
||||
});
|
||||
|
||||
Overlay.on('alert', function (ev) {
|
||||
if (ev.type === 'raid') konfetti(Math.min(180, 70 + (ev.amount || 0) * 2));
|
||||
else if (ev.type === 'massgift') konfetti(Math.min(180, 60 + (ev.amount || 0) * 10));
|
||||
else if (ev.type === 'sub' || ev.type === 'resub' || ev.type === 'subgift') konfetti(80);
|
||||
else if (ev.type === 'cheer' && (ev.amount || 0) >= 100) konfetti(60);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Pausenbild</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Das einzige Overlay mit deckendem Hintergrund: es ersetzt das Bild, statt
|
||||
* darueber zu liegen. Gedacht als eigene OBS-Szene fuer "gleich geht es los",
|
||||
* Pause und Ende. Text ueber ?text= und ?unter=. */
|
||||
body { background: #0a0813; }
|
||||
|
||||
#grund {
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
radial-gradient(42% 42% at 22% 28%, rgba(169, 112, 255, .55), transparent 70%),
|
||||
radial-gradient(38% 38% at 78% 32%, rgba(79, 209, 197, .45), transparent 70%),
|
||||
radial-gradient(45% 45% at 55% 82%, rgba(255, 79, 163, .40), transparent 70%);
|
||||
filter: blur(48px);
|
||||
animation: treiben 28s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes treiben {
|
||||
0% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
50% { transform: translate(-3%, 2.5%) scale(1.1) rotate(4deg); }
|
||||
100% { transform: translate(3%, -2%) scale(1.04) rotate(-3deg); }
|
||||
}
|
||||
|
||||
/* Feines Raster darueber: nimmt dem Verlauf das Billige und gibt dem Bild
|
||||
* eine Struktur, die auch nach der Twitch-Kompression noch da ist. */
|
||||
#raster {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
mask-image: radial-gradient(70% 70% at 50% 50%, #000, transparent);
|
||||
}
|
||||
|
||||
#mitte {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(64px, 8.5vw, 148px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -.035em;
|
||||
/* Unterlaengen brauchen Platz. `background-clip: text` faerbt nur, was
|
||||
* innerhalb der Polsterbox liegt - was darueber hinausragt, bleibt
|
||||
* durchsichtig und sieht abgeschnitten aus. Bei fetter Schrift in dieser
|
||||
* Groesse reicht eine knappe Zeilenhoehe dafuer nicht.
|
||||
*
|
||||
* Nachgemessen am "g" in "Gleich geht es los": mit 1.04 und .06em fehlte
|
||||
* noch ein Stueck vom Bogen. */
|
||||
line-height: 1.18;
|
||||
padding-bottom: .14em;
|
||||
background: linear-gradient(100deg, #fff 18%, var(--lila) 48%, var(--tuerkis) 78%, #fff 96%);
|
||||
background-size: 260% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: schimmern 7s linear infinite;
|
||||
filter: drop-shadow(0 10px 40px rgba(0, 0, 0, .55));
|
||||
}
|
||||
@keyframes schimmern { to { background-position: 260% 0; } }
|
||||
|
||||
#unter {
|
||||
font-size: clamp(20px, 2vw, 30px);
|
||||
color: var(--schrift-leise);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/* Der Taktgeber: fuenf Balken, die atmen. Ohne bewegtes Element wirkt ein
|
||||
* Standbild wie ein eingefrorener Stream, und Zuschauer laden neu. */
|
||||
#takt { display: flex; gap: 12px; align-items: flex-end; height: 68px; margin-top: 10px; }
|
||||
#takt i {
|
||||
display: block;
|
||||
width: 13px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(180deg, var(--tuerkis), var(--lila));
|
||||
animation: atmen 1.5s ease-in-out infinite;
|
||||
}
|
||||
#takt i:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||
#takt i:nth-child(2) { height: 75%; animation-delay: .18s; }
|
||||
#takt i:nth-child(3) { height: 100%; animation-delay: .36s; }
|
||||
#takt i:nth-child(4) { height: 65%; animation-delay: .54s; }
|
||||
#takt i:nth-child(5) { height: 35%; animation-delay: .72s; }
|
||||
@keyframes atmen {
|
||||
0%, 100% { transform: scaleY(.35); opacity: .6; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
|
||||
#fuss {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 42px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 44px;
|
||||
font-size: 22px;
|
||||
color: var(--schrift-leise);
|
||||
}
|
||||
#fuss b { color: var(--schrift); font-weight: 600; }
|
||||
#uhr { font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="grund"></div>
|
||||
<div id="raster"></div>
|
||||
<div id="mitte">
|
||||
<h1 id="gross">Gleich geht es los</h1>
|
||||
<div id="unter"></div>
|
||||
<div id="takt"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<div id="fuss">
|
||||
<span id="uhr"></span>
|
||||
<span>twitch.tv/<b id="kanal">abdulive_</b></span>
|
||||
<span id="thema"></span>
|
||||
</div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.getElementById('gross').textContent = Overlay.param('text', 'Gleich geht es los');
|
||||
document.getElementById('unter').textContent = Overlay.param('unter', '');
|
||||
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||
|
||||
var uhr = document.getElementById('uhr');
|
||||
function zeit() {
|
||||
uhr.textContent = new Date().toLocaleTimeString('de-DE',
|
||||
{ hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
zeit();
|
||||
setInterval(zeit, 10000);
|
||||
|
||||
// Laeuft der Stream schon, steht unten, worum es geht. Das ist der
|
||||
// Unterschied zwischen einem Pausenbild und einem Standbild.
|
||||
var thema = document.getElementById('thema');
|
||||
function stand(s) {
|
||||
thema.textContent = s.live && s.category ? 'heute: ' + s.category : '';
|
||||
}
|
||||
Overlay.on('stream', stand);
|
||||
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,122 @@
|
||||
/* Gemeinsames Aussehen aller Browser-Sources.
|
||||
*
|
||||
* Zwei Dinge gelten auf jeder Seite und sind der Grund, warum es diese Datei
|
||||
* gibt:
|
||||
*
|
||||
* 1. Der Hintergrund ist durchsichtig. Eine Browser-Source mit weissem
|
||||
* Hintergrund ist der haeufigste Anfaengerfehler, und man sieht ihn erst
|
||||
* in der Aufzeichnung.
|
||||
* 2. Text liegt ueber bewegtem Bild. Reine Farbe reicht dafuer nicht -
|
||||
* jeder Text bekommt eine dunkle Kontur, sonst verschwindet er im hellen
|
||||
* Gameplay. Deshalb `paint-order` und der Schatten unten.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Twitch-Lila wie im Discord-Bauplan, damit Stream und Server zusammengehoeren. */
|
||||
--lila: #a970ff;
|
||||
--lila-tief: #6441a5;
|
||||
--tuerkis: #4fd1c5;
|
||||
--pink: #ff4fa3;
|
||||
--gold: #ffc53d;
|
||||
--gruen: #4ade80;
|
||||
--rot: #ef4444;
|
||||
|
||||
--tinte: #120e1d;
|
||||
--tinte-klar: rgba(18, 14, 29, .72);
|
||||
--schrift: #f6f3ff;
|
||||
--schrift-leise: #c5bde0;
|
||||
|
||||
--rund: 14px;
|
||||
--schatten: 0 12px 40px rgba(0, 0, 0, .55);
|
||||
--glanz: inset 0 1px 0 rgba(255, 255, 255, .12);
|
||||
|
||||
/* Segoe UI Variable gibt es auf Windows 11 - Abdus Rechner. Der Rest der
|
||||
* Liste ist Rueckfall fuer Entwicklung auf anderen Systemen. */
|
||||
--font: "Segoe UI Variable Display", "Segoe UI", Inter, system-ui, sans-serif;
|
||||
--font-zahl: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: var(--font);
|
||||
color: var(--schrift);
|
||||
/* OBS rendert mit eigener Skalierung; ohne das wirken Kanten weich. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.kontur {
|
||||
paint-order: stroke fill;
|
||||
-webkit-text-stroke: 4px rgba(0, 0, 0, .55);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, .7);
|
||||
}
|
||||
|
||||
/* Glasplatte: der Traeger fuer alles, was ueber dem Spiel steht. */
|
||||
.glas {
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: var(--rund);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
}
|
||||
|
||||
/* Der Lichtstreifen, der einmal ueber eine Flaeche wandert. Wird von den
|
||||
* Einblendungen benutzt und ist der Grund, warum eine Einblendung "teuer"
|
||||
* aussieht, ohne dass ein Video dahintersteckt. */
|
||||
@keyframes streifen {
|
||||
from { transform: translateX(-120%) skewX(-18deg); }
|
||||
to { transform: translateX(320%) skewX(-18deg); }
|
||||
}
|
||||
|
||||
.streifen::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 28%;
|
||||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .3), transparent);
|
||||
animation: streifen 1.5s cubic-bezier(.3, 0, .2, 1) .35s both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulsieren {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .55; transform: scale(.82); }
|
||||
}
|
||||
|
||||
/* Zum Ansehen im normalen Browser: ?probe=1 legt einen dunklen Grund unter
|
||||
* die Seite. Ohne den steht heller Text auf Weiss, und man sieht nichts -
|
||||
* in OBS liegt das Overlay ueber dem Spiel, im Browser ueber gar nichts. */
|
||||
body.probe {
|
||||
background:
|
||||
radial-gradient(60% 60% at 30% 25%, #2a1f45, transparent 70%),
|
||||
radial-gradient(50% 50% at 75% 70%, #123c3a, transparent 70%),
|
||||
#0b0912;
|
||||
}
|
||||
|
||||
/* Wer im Browser nachsieht, will nicht raten, ob die Verbindung steht.
|
||||
* In OBS ist der Punkt unsichtbar, weil er nur bei Verlust erscheint. */
|
||||
.getrennt {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
font: 500 12px/1 var(--font-zahl);
|
||||
color: var(--rot);
|
||||
background: rgba(0, 0, 0, .7);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
opacity: 0;
|
||||
transition: opacity .4s;
|
||||
}
|
||||
body.offline .getrennt { opacity: 1; }
|
||||
|
||||
/* Wer mit prefers-reduced-motion arbeitet, bekommt in OBS trotzdem die
|
||||
* Animationen - eine Browser-Source hat keinen Menschen mit Vestibular-
|
||||
* problemen davor. Die Einstellung wird hier bewusst nicht beachtet. */
|
||||
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>abdulive - Vorlesen</title>
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Die Seite zeigt fast nichts. Sie ist eine Tonquelle mit einer kleinen
|
||||
* Sichtkontrolle daneben - man soll sehen, was gerade gesprochen wird, und
|
||||
* zwar auch als Zuschauer. Wer nur hoert, was vorgelesen wird, kann nicht
|
||||
* nachlesen, wenn er es nicht verstanden hat. */
|
||||
#kasten {
|
||||
position: fixed;
|
||||
left: 16px; right: 16px; bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--tinte-klar);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
box-shadow: var(--schatten), var(--glanz);
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition: opacity .3s, transform .3s;
|
||||
}
|
||||
#kasten.an { opacity: 1; transform: none; }
|
||||
|
||||
.wer {
|
||||
font-size: 13px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--tuerkis);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.was { font-size: 22px; line-height: 1.35; }
|
||||
|
||||
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
|
||||
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
|
||||
#fehlt {
|
||||
position: fixed;
|
||||
left: 16px; top: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239, 68, 68, .9);
|
||||
font: 600 15px/1.3 var(--font-zahl);
|
||||
max-width: 520px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fehlt">
|
||||
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
|
||||
Vorgelesen wird nichts.
|
||||
</div>
|
||||
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
|
||||
|
||||
<script src="/client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var kasten = document.getElementById('kasten');
|
||||
var werEl = document.getElementById('wer');
|
||||
var wasEl = document.getElementById('was');
|
||||
|
||||
var sprache = Overlay.param('sprache', 'de-DE');
|
||||
var tempo = Overlay.param('tempo', 1);
|
||||
var lautstaerke = Overlay.param('laut', 1);
|
||||
var warteschlange = [];
|
||||
var laeuft = false;
|
||||
|
||||
/* Ob die Browser-Quelle ueberhaupt sprechen kann, weiss man erst zur
|
||||
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
|
||||
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
|
||||
* warum. */
|
||||
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
|
||||
typeof window.SpeechSynthesisUtterance !== 'undefined';
|
||||
if (!kannSprechen) {
|
||||
document.getElementById('fehlt').style.display = 'block';
|
||||
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
|
||||
}
|
||||
|
||||
/* Stimmen werden asynchron geladen. Beim ersten Aufruf ist die Liste oft
|
||||
* noch leer - deshalb bei jedem Sprechen neu nachsehen statt einmal am
|
||||
* Anfang. */
|
||||
function stimme() {
|
||||
if (!kannSprechen) return null;
|
||||
var alle = window.speechSynthesis.getVoices() || [];
|
||||
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
|
||||
return passend[0] || alle[0] || null;
|
||||
}
|
||||
|
||||
function zeige(name, text) {
|
||||
werEl.textContent = name;
|
||||
wasEl.textContent = text;
|
||||
kasten.classList.add('an');
|
||||
}
|
||||
function verstecke() { kasten.classList.remove('an'); }
|
||||
|
||||
function naechste() {
|
||||
var n = warteschlange.shift();
|
||||
if (!n) { laeuft = false; verstecke(); return; }
|
||||
laeuft = true;
|
||||
zeige(n.name, n.text);
|
||||
|
||||
if (!kannSprechen) {
|
||||
// Wenigstens anzeigen, und nach der geschaetzten Lesedauer weiter.
|
||||
setTimeout(naechste, Math.min(15000, 2000 + n.text.length * 60));
|
||||
return;
|
||||
}
|
||||
|
||||
var rede = new SpeechSynthesisUtterance(n.text);
|
||||
rede.lang = sprache;
|
||||
rede.rate = tempo;
|
||||
rede.volume = lautstaerke;
|
||||
var v = stimme();
|
||||
if (v) rede.voice = v;
|
||||
|
||||
var weiter = false;
|
||||
var gehWeiter = function () {
|
||||
if (weiter) return;
|
||||
weiter = true;
|
||||
setTimeout(naechste, 400);
|
||||
};
|
||||
rede.onend = gehWeiter;
|
||||
rede.onerror = function (e) {
|
||||
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||
gehWeiter();
|
||||
};
|
||||
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
|
||||
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
|
||||
setTimeout(gehWeiter, 5000 + n.text.length * 250);
|
||||
|
||||
window.speechSynthesis.speak(rede);
|
||||
}
|
||||
|
||||
Overlay.on('tts', function (ev) {
|
||||
warteschlange.push({ name: ev.name, text: ev.text });
|
||||
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
||||
if (!laeuft) naechste();
|
||||
});
|
||||
|
||||
// Panikschalter: sofort still, Warteschlange weg.
|
||||
Overlay.on('still', function () {
|
||||
warteschlange.length = 0;
|
||||
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
|
||||
laeuft = false;
|
||||
verstecke();
|
||||
});
|
||||
|
||||
// Nach dem Neuverbinden NICHT nachholen: eine Nachricht von vor zehn
|
||||
// Minuten vorzulesen ist schlimmer, als sie zu verpassen.
|
||||
Overlay.on('state', function () { });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user