15 Commits
Author SHA1 Message Date
ZeasyAlexandClaude Opus 5.5 d00550aa37 Rueckbau: gestrichene Werkzeuge entfernt, Hub schreibt keinen Chat mehr mit
Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedaechtnis, Punkteshop
und Recap samt Platzhaltern, Spezifikationen und Konfiguration. Der Hub
schickt am Chat zusaetzlich die Sendezeit mit, fuer die Uhrzeit im Pult.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:16:04 +01:00
ZeasyAlexandClaude Opus 5.5 227ecfdcfe Pult: Chat nur lesend, am Tablet Chat neben dem Reiter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:16:04 +01:00
ZeasyAlexandClaude Opus 5.5 9c64309c51 Uebergabe: Stream-PC verbunden, Chat-Abzeichen als Bilder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:19:10 +01:00
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
ZeasyAlexandClaude Opus 5.5 884e4517d8 Uebergabe: Pult-Freigabe als Abdus Handgriff, Abnahme an echtem OBS
Playwright-Mitschrift aus dem Repo genommen und ausgeschlossen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:00:50 +01:00
ZeasyAlexandClaude Opus 5.5 54dbe1265b Pult: Stream-PC per Tipp freigeben, Sound-Quelle legt er selbst in OBS an
Paket bauen und veroeffentlichen / paket (push) Successful in 42s
- ingest wuerfelt beim ersten Start einen Schluessel und meldet sich beim
  Hub; der antwortet 202, bis im Pult jemand auf Freigeben tippt
- nach dem Verbinden schickt der Hub die Adresse von /sounds, ingest legt
  Szene "abdulive Ton" mit der Browser-Source an und haengt sie in jede Szene
- fern:verbinden bleibt als Weg von Hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:03:06 +01:00
ZeasyAlexandClaude Opus 5.5 32416d1916 Uebergabe: Pult ausgerollt, Paket 0.5.6, Pipeline laeuft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:19:55 +01:00
ZeasyAlexandClaude Opus 5.5 5eaae9129f Paketbau: whisper-Archiv auch in den b-Veroeffentlichungen suchen, Manifest mitnehmen
Paket bauen und veroeffentlichen / paket (push) Successful in 43s
whisper.cpp v1.9.5 ist als latest markiert, hat aber keine Archive; die
Windows-Programme liegen in b5454. Daran ist der erste Lauf der Pipeline
gescheitert. Dazu fehlte manifest.webmanifest im Paket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:58:01 +01:00
ZeasyAlexandClaude Opus 5.5 8be195d938 Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole)
- Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen
  ueber die neue Browser-Source /sounds
- Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem
  Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS
- Browser-Sources melden, welche Seite sie sind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:48 +01:00
ZeasyAlexandClaude Opus 5.5 70448967f5 Uebergabe: Foren, Spielvorschlaege eingepflegt, Ankuendigung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:15:57 +02:00
ZeasyAlexandClaude Opus 5.5 5494c6d24d Discord: eigener Kanal fuer Beitritte, Spielvorschlaege und Reactions als Forum
Beitrittsmeldungen liefen nach #allgemein, weil Discord ohne Angabe den
ersten Kanal nimmt. Jetzt gehen sie nach #begruessung (SYSTEM_CHANNEL).

Die von Abdu angelegten Textkanaele spielvorschlaege und
reaction-einsendungen sind jetzt Foren im Bauplan: ein Beitrag je Spiel
bzw. Clip, 👍 als Standard-Reaktion, Stand-Tags nur fuer Mods. apply.ts
kann dafuer Tags und Standard-Reaktion anlegen und nachziehen; fremde
Tags bleiben mit ihrer ID stehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:02 +02:00
ZeasyAlexandClaude Opus 5.5 81ab865de0 Uebergabe: Liegestuetze, Vorlese-Freigabe, naechste Sitzung Overlay neu
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 02:47:07 +02:00
ZeasyAlexandClaude Opus 5.5 094a791da2 Liegestuetze: pro Stream mitzaehlen, wie viele gemacht sind
Herunterzaehlen mit -1/-5/-10 zaehlt als gemacht, Setzen bleibt eine
Korrektur und zaehlt nicht. Der Stand "gemacht" beginnt mit jedem neuen
Stream bei 0 (nicht beim Neustart des Hubs mitten im Stream), steht im
Overlay unter der offenen Zahl und laesst sich auf der Uebersicht
korrigieren.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:46:32 +02:00
ZeasyAlexandClaude Opus 5.5 eb9eab949e Liegestuetze: Zaehler aus Kanalpunkten, Overlay und Korrektur von Hand
Neue Aktion `liegestuetze` fuer Belohnungen: jede Einloesung zaehlt um
`anzahl` hoch (Vorgabe 5). Der Stand liegt in der Datenbank und
uebersteht einen Neustart. Browser-Source /liegestuetze zeigt ihn, die
Uebersichtsseite hat Knoepfe zum Herunterzaehlen und Setzen.

Nebenbei: POST /tts landete bei der Overlay-Seite statt bei der
Freigabe. Seiten werden jetzt nur noch bei GET ausgeliefert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:15:50 +02:00
ZeasyAlexandClaude Opus 5.5 5622499877 Vorlesen: Wortfilter trifft nicht mehr jedes w, Stimme Thorsten
"www" in der Blockliste wurde beim Zusammenziehen gezogener Buchstaben zu
"w" und traf als kurzer Eintrag jedes Wort, das mit w anfaengt. Im Stream
flog damit fast jede Nachricht raus ("wie", "weg"), die Punkte gingen
zurueck. Was nach dem Normalisieren unter 3 Buchstaben hat, trifft jetzt
nur noch als ganzes Wort.

de_DE-mls-medium, Sprecher 0, klang verwaschen. Die Phoneme stimmten, es
lag am Modell. Vorgabe ist jetzt de_DE-thorsten-medium; im Hub schon so
eingestellt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:17:17 +02:00
76 changed files with 4976 additions and 625 deletions

No files matched your search

+3
View File
@@ -32,3 +32,6 @@ docs/handbuch/*.log
docs/handbuch/*.out docs/handbuch/*.out
docs/handbuch/*.toc docs/handbuch/*.toc
docs/handbuch/*.png docs/handbuch/*.png
# Mitschriften der Browser-Automatisierung
.playwright-mcp/
+10 -15
View File
@@ -11,23 +11,18 @@ Betrieb über Docker Compose.
Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese Zielgröße des Kanals: ~5–30 gleichzeitige Zuschauer. Alles hier ist auf diese
Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**. Phase optimiert — nicht auf Skalierung, sondern auf **Wachstum und Bindung**.
## Die neun Tools ## Was es gibt
| # | Tool | Ordner | Zweck | Status | | Teil | Ordner | Zweck |
|---|------|--------|-------|--------| |------|--------|-------|
| 1 | Clip-to-Shorts | `apps/clipper` | Clips → 9:16 mit Untertiteln, Upload-fertig | geplant | | Hub | `apps/hub` | Läuft zentral im LXC: Chat, Overlay für OBS, Pult, Kanalpunkte, Discord, Go-Live-Ping |
| 2 | Go-Live-Broadcaster | `apps/broadcaster` | Stream-Start → Discord/Bluesky/Instagram | geplant | | Overlay und Pult | `apps/overlay-server` + `apps/overlay-web` | Browser-Sources für OBS, Pult am Handy und Tablet |
| 3 | Raid- & Netzwerk-Tool | `apps/raid-scout` | Raid-Ziele finden, Raid-Historie führen | geplant | | Go-Live-Ping | `apps/broadcaster` | Stream-Start → Discord |
| 4 | Analytics-Dashboard | `apps/dashboard` | Viewer vs. Chat, Titel-Performance, Retention | geplant | | Ingest | `apps/ingest` | Läuft auf dem Stream-PC: OBS-Anbindung, `!clip`, 9:16-Render mit Untertiteln |
| 5 | VOD-Kapitel | `apps/vod-chapters` | Szenen-/Kategoriewechsel → Timestamps | geplant |
| 6 | Community-Gedächtnis | `apps/community` | Chatter-Profile, Begrüßungen, Overlay-Hinweise | geplant |
| 7 | Chat steuert Dinge | `apps/overlay-server` + `apps/overlay-web` | Overlay, Soundboard, Minigames, Umfragen | geplant |
| 8 | Punktesystem + Shop | `apps/loyalty` | Punkte mit echten Konsequenzen | geplant |
| 9 | Stream-Recap | `apps/recap` | Zusammenfassung nach Streamende in Discord | geplant |
Dazu der Dienst, der unter allen anderen liegt: Die früher geplanten Werkzeuge (Raid-Scout, Auswertung, VOD-Kapitel,
Community-Gedächtnis, Punkteshop, Recap) sind gestrichen und mit ihren
| — | **Ingest-Daemon** | `apps/ingest` | IRC + Helix → DB, Einrichtungsseite | **läuft** | Spezifikationen entfernt.
## Doku ## Doku
+50
View File
@@ -690,6 +690,51 @@ async function webPasswort(args: string[]): Promise<void> {
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`); console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
} }
/**
* Den Stream-PC beim Hub anmelden. Prueft vorher, ob der Hub den Schluessel
* annimmt - ein Tippfehler soll hier auffallen und nicht erst im Stream, wenn
* der Szenenknopf am Handy nichts tut.
*/
async function fernVerbinden(args: string[]): Promise<void> {
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
const [adresse, schluessel] = args;
if (adresse === 'aus') {
updateSecrets((s) => { delete s.fern; });
console.log('Fernsteuerung getrennt. Wirkt nach dem naechsten Start von abdulive ingest.');
return;
}
if (!adresse || !schluessel) {
const da = readSecrets().fern;
console.log(da ? `Eingerichtet fuer ${da.url}.` : 'Nicht eingerichtet.');
console.log('Aufruf: abdulive fern:verbinden <adresse> <schluessel> (steht auf der Uebersichtsseite)');
return;
}
const url = adresse.replace(/\/+$/, '');
if (!/^https?:\/\//.test(url)) throw new Error('Die Adresse muss mit https:// anfangen.');
const probe = new AbortController();
let status: number;
try {
const res = await fetch(`${url}/fern/befehle`, {
headers: { authorization: `Bearer ${schluessel}` },
signal: AbortSignal.any([probe.signal, AbortSignal.timeout(10_000)]),
});
status = res.status;
} catch (err) {
throw new Error(`Hub unter ${url} nicht erreichbar: ${(err as Error).message}`);
} finally {
// Die Leitung nur oeffnen, um den Schluessel zu pruefen. Offen gelassen
// haette sie der Hub als verbundenen PC gezaehlt.
probe.abort();
}
if (status === 403) throw new Error('Der Hub lehnt den Schluessel ab. Neu von der Uebersichtsseite kopieren.');
if (status !== 200) throw new Error(`Der Hub antwortet ${status}. Laeuft dort eine Fassung mit Pult?`);
updateSecrets((s) => { s.fern = { url, key: schluessel }; });
console.log(`${C.ok}Verbunden.${C.off} Der Hub nimmt den Schluessel an.`);
console.log('Jetzt abdulive ingest neu starten - danach steuert das Pult OBS.');
}
// ---------------------------------------------------------------- golive // ---------------------------------------------------------------- golive
/** /**
@@ -901,6 +946,10 @@ abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern) ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen web:passwort [wort] Passwort fuer die Weboberflaeche setzen
fern:verbinden <adresse> <schluessel>
Stream-PC beim Hub anmelden, damit das Pult OBS
steuern kann. Der Befehl steht fertig auf der
Uebersichtsseite des Hubs. "aus" statt Adresse trennt.
check-env Umgebung pruefen check-env Umgebung pruefen
db:migrate Migrationen anwenden db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
@@ -956,6 +1005,7 @@ try {
} }
case 'overlay': await overlayOnly(args); break; case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break; case 'web:passwort': await webPasswort(args); break;
case 'fern:verbinden': await fernVerbinden(args); break;
case 'discord:bot': await discordBot(); break; case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/clipper
Clip-to-Shorts-Pipeline: Trigger -> Replay/Clip -> 9:16 -> Untertitel -> Upload-Kandidat.
Spezifikation: [docs/projects/clip-shorts.md](../../docs/projects/clip-shorts.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/community
Community-Gedaechtnis: Begruessungen, Meilensteine, Overlay-Hinweis. Modul im overlay-server.
Spezifikation: [docs/projects/community-memory.md](../../docs/projects/community-memory.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/dashboard
Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten.
Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md)
+58 -8
View File
@@ -30,6 +30,7 @@ import { createBot } from '#discord/bot.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { readSecrets } from '#twitch/secrets.ts'; import { readSecrets } from '#twitch/secrets.ts';
import { createEventSub } from '#twitch/eventsub.ts'; import { createEventSub } from '#twitch/eventsub.ts';
import { createBadges } from '#twitch/badges.ts';
import { createIrc, type UserNotice } from '#twitch/irc.ts'; import { createIrc, type UserNotice } from '#twitch/irc.ts';
import { streamWechsel } from '#twitch/stream-state.ts'; import { streamWechsel } from '#twitch/stream-state.ts';
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts'; import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
@@ -37,6 +38,7 @@ import {
ausEventSub, createDrossel, createWarteschlange, entscheiden, ausEventSub, createDrossel, createWarteschlange, entscheiden,
} from './rewards.ts'; } from './rewards.ts';
import { createAudioAblage } from './audio-ablage.ts'; import { createAudioAblage } from './audio-ablage.ts';
import { createLiegestuetze } from './liegestuetze.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createSpikeDetector } from '../../ingest/src/spike.ts'; import { createSpikeDetector } from '../../ingest/src/spike.ts';
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts'; import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
@@ -95,6 +97,10 @@ export async function runHub(): Promise<void> {
// ---- Kanalpunkte // ---- Kanalpunkte
const warteschlange = createWarteschlange(); const warteschlange = createWarteschlange();
const drossel = createDrossel(); const drossel = createDrossel();
const liegestuetze = createLiegestuetze(db);
// Gleich zu Beginn in den Bus, sonst zeigt eine frisch verbundene
// Browser-Source 0, bis die naechste Einloesung kommt.
bus.publish({ kind: 'liegestuetze', ...liegestuetze.stand() });
/** /**
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben * Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
@@ -189,6 +195,10 @@ export async function runHub(): Promise<void> {
}); });
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 }); if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 }); if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
if (ent.aktion === 'liegestuetze') {
const dazu = ent.plan.anzahl ?? 5;
bus.publish({ kind: 'liegestuetze', ...liegestuetze.aendern(dazu), dazu, name: e.userName });
}
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`); log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED'); void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
break; break;
@@ -236,6 +246,21 @@ export async function runHub(): Promise<void> {
const bot = createBot(); const bot = createBot();
if (bot) void bot.run(abort.signal); if (bot) void bot.run(abort.signal);
// ---- Abzeichen-Bilder fuer den Chat. Twitch aendert sie selten, ein
// neues Abo-Abzeichen des Kanals ist nach spaetestens sechs Stunden da.
const badges = createBadges();
void (async () => {
while (!abort.signal.aborted) {
if (helix.canReadStream()) {
const id = state.broadcasterId || cfg.channel.userId ||
(await helix.userByLogin(cfg.channel.login).catch(() => null))?.id || null;
if (id) state.broadcasterId = id;
await badges.laden(id);
}
try { await sleep(6 * 60 * 60_000, abort.signal); } catch { return; }
}
})();
// ---- Chat // ---- Chat
const irc = createIrc(cfg.channel.login, { const irc = createIrc(cfg.channel.login, {
onConnected: (anonymous) => { onConnected: (anonymous) => {
@@ -244,11 +269,9 @@ export async function runHub(): Promise<void> {
}, },
onMessage: (msg) => { onMessage: (msg) => {
try { try {
repo.recordMessage(msg, state.streamId); // Chatnachrichten landen nicht mehr in der Datenbank. Gelesen haben
// sie nur Recap und Auswertung, und die gibt es nicht mehr.
state.messages++; state.messages++;
repo.event('chat.message', {
text: msg.text, login: msg.login, isFirst: msg.isFirst, bits: msg.bits,
}, state.streamId, msg.userId, msg.ts);
bus.publish({ bus.publish({
kind: 'chat', kind: 'chat',
@@ -264,7 +287,9 @@ export async function runHub(): Promise<void> {
mod: msg.isMod, mod: msg.isMod,
sub: msg.isSub, sub: msg.isSub,
broadcaster: msg.isBroadcaster, broadcaster: msg.isBroadcaster,
badges: badges.aufloesen(msg.badges),
bits: msg.bits, bits: msg.bits,
ts: msg.ts,
}); });
if (msg.isFirst) { if (msg.isFirst) {
@@ -321,6 +346,7 @@ export async function runHub(): Promise<void> {
goLive: announcer.lastResult() ?? 'noch nichts', goLive: announcer.lastResult() ?? 'noch nichts',
overlayClients: bus.clients(), overlayClients: bus.clients(),
ttsWartend: warteschlange.liste().length, ttsWartend: warteschlange.liste().length,
liegestuetze: liegestuetze.stand(),
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit', ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
note: state.chatAnonymous note: state.chatAnonymous
@@ -360,11 +386,38 @@ export async function runHub(): Promise<void> {
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert'); log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
}, },
}, },
liegestuetze: {
stand: () => liegestuetze.stand(),
aendern: (n) => {
const neu = liegestuetze.aendern(n);
bus.publish({ kind: 'liegestuetze', ...neu });
log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ` +
`${neu.offen} offen, ${neu.gemacht} gemacht`);
return neu;
},
setzen: (n) => {
const neu = liegestuetze.setzen(n);
bus.publish({ kind: 'liegestuetze', ...neu });
log.info(`Liegestuetze von Hand gesetzt -> ${neu.offen} offen`);
return neu;
},
gemachtSetzen: (n) => {
const neu = liegestuetze.gemachtSetzen(n);
bus.publish({ kind: 'liegestuetze', ...neu });
log.info(`Liegestuetze gemacht von Hand gesetzt -> ${neu.gemacht}`);
return neu;
},
},
}); });
void server; void server;
// ---- Zuschauerzahlen und Stream-Sitzungen // ---- Zuschauerzahlen und Stream-Sitzungen
void pollStream(cfg, repo, state, bus, abort.signal, (stream) => announcer.announce(stream)); void pollStream(cfg, repo, state, bus, abort.signal, (stream) => {
announcer.announce(stream);
// Nur bei einem wirklich neuen Stream, nicht beim Neustart des Hubs
// mitten im Stream: dann wird die Sitzung uebernommen, nicht gestartet.
bus.publish({ kind: 'liegestuetze', ...liegestuetze.neuerStream() });
});
// ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token // ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token
// wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als // wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als
@@ -512,9 +565,6 @@ async function pollStream(
state.startedLiveAt = live.started_at; state.startedLiveAt = live.started_at;
state.lastSeenLive = now; state.lastSeenLive = now;
state.viewers = live.viewer_count; state.viewers = live.viewer_count;
if (state.streamId) {
repo.sample(state.streamId, new Date().toISOString(), live.viewer_count);
}
sendState(true); sendState(true);
} }
} }
+89
View File
@@ -0,0 +1,89 @@
// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben und
// was Abdu davon geschafft hat. Er darf weder beim Neustart vergessen noch
// ins Negative laufen, und eine Korrektur ist keine Liegestuetze.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { migrate } from '#db/index.ts';
import { createLiegestuetze, HOECHSTENS } from './liegestuetze.ts';
import { entscheiden, type RewardsConfig } from './rewards.ts';
import { zahlAus } from '../../overlay-server/src/server.ts';
function datei(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-lz-'));
return path.join(dir, 't.db');
}
function oeffnen(file: string): DatabaseSync {
const db = new DatabaseSync(file);
migrate(db);
return db;
}
test('Liegestuetze: zaehlt hoch und herunter, nie unter 0', () => {
const lz = createLiegestuetze(oeffnen(datei()));
assert.deepEqual(lz.stand(), { offen: 0, gemacht: 0 });
assert.equal(lz.aendern(10).offen, 10);
assert.equal(lz.aendern(10).offen, 20);
assert.equal(lz.aendern(-5).offen, 15);
assert.equal(lz.aendern(-100).offen, 0);
assert.equal(lz.setzen(42).offen, 42);
assert.equal(lz.setzen(-3).offen, 0);
assert.equal(lz.setzen(10_000_000).offen, HOECHSTENS);
});
test('Liegestuetze: Herunterzaehlen ist gemacht, Setzen nicht', () => {
const lz = createLiegestuetze(oeffnen(datei()));
lz.aendern(20);
assert.deepEqual(lz.aendern(-5), { offen: 15, gemacht: 5 });
// Nur was wirklich offen war, kann gemacht sein.
assert.deepEqual(lz.aendern(-100), { offen: 0, gemacht: 20 });
lz.aendern(10);
assert.deepEqual(lz.setzen(3), { offen: 3, gemacht: 20 }, 'Korrektur zaehlt nicht');
assert.deepEqual(lz.gemachtSetzen(7), { offen: 3, gemacht: 7 });
});
test('Liegestuetze: neuer Stream setzt gemacht zurueck, offen bleibt', () => {
const lz = createLiegestuetze(oeffnen(datei()));
lz.aendern(30);
lz.aendern(-10);
assert.deepEqual(lz.neuerStream(), { offen: 20, gemacht: 0 });
});
test('Liegestuetze: der Stand ueberlebt einen Neustart', () => {
const file = datei();
const erst = oeffnen(file);
const lz = createLiegestuetze(erst);
lz.aendern(30);
lz.aendern(-5);
erst.close();
assert.deepEqual(createLiegestuetze(oeffnen(file)).stand(), { offen: 25, gemacht: 5 });
});
test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => {
const cfg: RewardsConfig = {
enabled: true,
belohnungen: [{ titel: '5 Liegestütze', aktion: 'liegestuetze', anzahl: 5 }],
tts: { freigabe: true, maxZeichen: 200, blockliste: [], proPersonProStunde: 3 },
};
const ent = entscheiden({
id: 'r1', rewardId: 'x', rewardTitle: '5 liegestütze', userId: 'u1', userName: 'Kevin',
text: '', kosten: 2000, ts: '2026-10-04T20:00:00Z',
}, cfg);
assert.equal(ent.art, 'ausfuehren');
assert.ok(ent.art === 'ausfuehren' && ent.aktion === 'liegestuetze' && ent.plan.anzahl === 5);
});
test('zahlAus: nur ganze Zahlen, alles andere ist null', () => {
assert.equal(zahlAus('10'), 10);
assert.equal(zahlAus(' -5 '), -5);
assert.equal(zahlAus('+3'), 3);
for (const x of ['', 'abc', '1.5', '1e3', '9999999', null, undefined]) {
assert.equal(zahlAus(x), null, String(x));
}
});
+63
View File
@@ -0,0 +1,63 @@
// Liegestuetze aus Kanalpunkten: was offen ist und was in diesem Stream
// schon gemacht wurde.
//
// Eine Einloesung zaehlt `offen` hoch. Abdu zaehlt auf der Uebersichtsseite
// herunter, wenn er sie gemacht hat - das wandert nach `gemacht`. Setzen ist
// dagegen eine Korrektur und zaehlt nicht als gemacht: wer einen Tippfehler
// behebt, hat dabei keine Liegestuetze gemacht.
//
// Beides liegt in der Datenbank: was die Zuschauer bezahlt haben, darf ein
// Neustart nicht loeschen. `gemacht` beginnt mit jedem neuen Stream bei 0.
import type { Db } from '#db/index.ts';
/** Mehr glaubt niemand, und ein Tippfehler mit sechs Nullen soll nicht im Bild stehen. */
export const HOECHSTENS = 99_999;
export type LiegestuetzStand = { offen: number; gemacht: number };
export type Liegestuetze = {
stand: () => LiegestuetzStand;
/**
* Zaehlt `offen` um `n` hoch oder (negativ) herunter, nie unter 0. Was
* dabei wirklich abgezogen wurde, kommt zu `gemacht`.
*/
aendern: (n: number) => LiegestuetzStand;
/** Setzt `offen` fest. Korrektur, zaehlt nicht als gemacht. */
setzen: (n: number) => LiegestuetzStand;
/** Setzt `gemacht` fest. Korrektur. */
gemachtSetzen: (n: number) => LiegestuetzStand;
/** Neuer Stream: `gemacht` auf 0, `offen` bleibt. */
neuerStream: () => LiegestuetzStand;
};
export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze {
const lesen = db.prepare('SELECT wert FROM zaehler WHERE name = ?');
const schreiben = db.prepare(`
INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert
`);
const OFFEN = name;
const GEMACHT = `${name}-gemacht`;
const wert = (n: string): number => (lesen.get(n) as { wert: number } | undefined)?.wert ?? 0;
const schreibe = (n: string, w: number): number => {
const sauber = Math.min(HOECHSTENS, Math.max(0, Math.trunc(w)));
schreiben.run(n, sauber, new Date().toISOString());
return sauber;
};
const stand = (): LiegestuetzStand => ({ offen: wert(OFFEN), gemacht: wert(GEMACHT) });
return {
stand,
aendern(n) {
const vorher = wert(OFFEN);
const nachher = schreibe(OFFEN, vorher + n);
if (nachher < vorher) schreibe(GEMACHT, wert(GEMACHT) + (vorher - nachher));
return stand();
},
setzen(n) { schreibe(OFFEN, n); return stand(); },
gemachtSetzen(n) { schreibe(GEMACHT, n); return stand(); },
neuerStream() { schreibe(GEMACHT, 0); return stand(); },
};
}
+9
View File
@@ -25,6 +25,15 @@ test('Wortfilter: kurze Eintraege nur am Wortanfang, sonst trifft es Harmloses',
assert.equal(wortfilterTreffer('N4Z1', LISTE), 'nazi'); assert.equal(wortfilterTreffer('N4Z1', LISTE), 'nazi');
}); });
test('Wortfilter: "www" wird zu "w" und darf nur als ganzes Wort treffen', () => {
// So im Stream passiert: jede Nachricht mit "wie" oder "weg" flog raus.
const liste = ['www', 'http'];
assert.equal(wortfilterTreffer('wie kann es sein, dass ich nicht mod bin?!', liste), undefined);
assert.equal(wortfilterTreffer('Ladiesman hat einen weg', liste), undefined);
assert.equal(wortfilterTreffer('geh auf www.example.de', liste), 'www');
assert.equal(wortfilterTreffer('https://example.de', liste), 'http');
});
test('Wortfilter: normaler Text geht durch', () => { test('Wortfilter: normaler Text geht durch', () => {
assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined); assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined);
}); });
+7 -1
View File
@@ -31,7 +31,8 @@ export type Einloesung = {
ts: string; ts: string;
}; };
export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; export type AktionsArt =
| 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
export type BelohnungsPlan = { export type BelohnungsPlan = {
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */ /** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
@@ -43,6 +44,8 @@ export type BelohnungsPlan = {
freigabe?: boolean; freigabe?: boolean;
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */ /** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
maxZeichen?: number; maxZeichen?: number;
/** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */
anzahl?: number;
}; };
export type RewardsConfig = { export type RewardsConfig = {
@@ -148,6 +151,8 @@ export function fuerFilter(text: string, mitLuecken: boolean): string {
* Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text * Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text
* gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur * gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur
* am Wortanfang: zusammengezogen stuende "nazi" sonst auch in "kann azubi". * am Wortanfang: zusammengezogen stuende "nazi" sonst auch in "kann azubi".
* Was danach kuerzer als 3 Buchstaben ist, nur als ganzes Wort: aus "www"
* wird "w", und das stuende am Anfang von "wie" und "weg".
*/ */
export function wortfilterTreffer(text: string, blockliste: string[]): string | undefined { export function wortfilterTreffer(text: string, blockliste: string[]): string | undefined {
const dicht = fuerFilter(text, false); const dicht = fuerFilter(text, false);
@@ -155,6 +160,7 @@ export function wortfilterTreffer(text: string, blockliste: string[]): string |
return blockliste.find((eintrag) => { return blockliste.find((eintrag) => {
const e = fuerFilter(eintrag, false); const e = fuerFilter(eintrag, false);
if (!e) return false; if (!e) return false;
if (e.length < 3) return woerter.includes(e);
return e.length >= 5 ? dicht.includes(e) : woerter.some((w) => w.startsWith(e)); return e.length >= 5 ? dicht.includes(e) : woerter.some((w) => w.startsWith(e));
}); });
} }
+151
View File
@@ -0,0 +1,151 @@
// Die Leitung zum Hub, Seite des Stream-PCs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { ObsClient } from '#obs/client.ts';
import {
befehlAus, einrichtenAus, SOUND_QUELLE, soundQuelleEinrichten, sseZerleger, standLesen, TON_SZENE,
} from './fern.ts';
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
const z = sseZerleger();
assert.deepEqual(z('retry: 2000\n\n'), [], 'retry allein ist keine Nachricht');
assert.deepEqual(z('data: {"a"'), []);
assert.deepEqual(z(':1}\n\ndata: 2\n\nda'), ['{"a":1}', '2']);
assert.deepEqual(z('ta: 3\r\n\r\n'), ['3'], 'auch mit Windows-Zeilenenden');
});
test('nur bekannte Befehle mit ID werden ausgefuehrt', () => {
assert.deepEqual(befehlAus({ id: 'x', art: 'szene', szene: 'Pause' }), { id: 'x', art: 'szene', szene: 'Pause' });
assert.deepEqual(befehlAus({ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true }),
{ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true });
assert.equal(befehlAus({ art: 'clip' }), null, 'ohne ID gibt es niemanden, dem man antwortet');
assert.equal(befehlAus({ id: 'x', art: 'beat' }), null);
assert.equal(befehlAus({ id: 'x', art: 'StopStream' }), null);
});
function falschesObs(antworten: Record<string, (d: Record<string, unknown>) => unknown>): ObsClient {
return {
run: async () => {},
isConnected: () => true,
request: async <T>(type: string, data?: unknown): Promise<T> => {
const f = antworten[type];
if (!f) throw new Error(`OBS lehnt ab (604): ${type}`);
return f((data ?? {}) as Record<string, unknown>) as T;
},
};
}
test('Stand: Szenen in OBS-Reihenfolge, nur Eingaenge mit Ton', async () => {
const obs = falschesObs({
GetSceneList: () => ({
currentProgramSceneName: 'Gameplay',
// OBS zaehlt von unten: Index 0 steht im Fenster ganz unten.
scenes: [{ sceneName: 'Pause', sceneIndex: 0 }, { sceneName: 'Gameplay', sceneIndex: 2 },
{ sceneName: 'Just Chatting', sceneIndex: 1 }],
}),
GetInputList: () => ({ inputs: [{ inputName: 'Mikro' }, { inputName: 'Facecam' }, { inputName: 'Desktop' }] }),
GetInputMute: (d) => {
if (d['inputName'] === 'Facecam') throw new Error('kein Ton');
return { inputMuted: d['inputName'] === 'Desktop' };
},
GetReplayBufferStatus: () => ({ outputActive: true }),
});
assert.deepEqual(await standLesen(obs), {
obs: true,
szenen: ['Gameplay', 'Just Chatting', 'Pause'],
aktuell: 'Gameplay',
ton: [{ name: 'Mikro', stumm: false }, { name: 'Desktop', stumm: true }],
puffer: true,
});
});
test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
const obs = falschesObs({
GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [{ sceneName: 'A', sceneIndex: 0 }] }),
GetInputList: () => ({ inputs: [] }),
});
const s = await standLesen(obs);
assert.equal(s.puffer, false);
assert.deepEqual(s.szenen, ['A']);
});
test('nur eine Adresse auf die Sound-Seite wird als Browser-Source angelegt', () => {
assert.deepEqual(einrichtenAus({ art: 'einrichten', soundsUrl: 'https://abdu.dimension47.de/sounds?key=4OB0-x_y' }),
{ art: 'einrichten', soundsUrl: 'https://abdu.dimension47.de/sounds?key=4OB0-x_y' });
assert.equal(einrichtenAus({ art: 'einrichten', soundsUrl: 'https://boese.example/irgendwas' }), null);
assert.equal(einrichtenAus({ art: 'einrichten', soundsUrl: 'javascript:alert(1)//sounds?key=a' }), null);
assert.equal(einrichtenAus({ art: 'szene', soundsUrl: 'https://a/sounds?key=a' }), null);
});
/** OBS zum Mitschreiben: Szenen, Quellen und was darin steckt. */
function obsMitSzenen(szenen: string[]) {
const inhalt = new Map<string, string[]>(szenen.map((s) => [s, []]));
const quellen = new Map<string, Record<string, unknown>>();
const aufrufe: string[] = [];
const obs: ObsClient = {
run: async () => {},
isConnected: () => true,
request: async <T>(type: string, data?: unknown): Promise<T> => {
const d = (data ?? {}) as Record<string, unknown>;
aufrufe.push(type);
switch (type) {
case 'GetSceneList': return { scenes: [...inhalt.keys()].map((sceneName) => ({ sceneName })) } as T;
case 'CreateScene': inhalt.set(String(d['sceneName']), []); return {} as T;
case 'GetInputSettings': {
const q = quellen.get(String(d['inputName']));
if (!q) throw new Error('OBS lehnt ab (600): keine Quelle');
return { inputSettings: q } as T;
}
case 'CreateInput':
quellen.set(String(d['inputName']), d['inputSettings'] as Record<string, unknown>);
inhalt.get(String(d['sceneName']))!.push(String(d['inputName']));
return {} as T;
case 'SetInputSettings':
Object.assign(quellen.get(String(d['inputName']))!, d['inputSettings']);
return {} as T;
case 'GetSceneItemList':
return { sceneItems: inhalt.get(String(d['sceneName']))!.map((sourceName) => ({ sourceName })) } as T;
case 'CreateSceneItem':
inhalt.get(String(d['sceneName']))!.push(String(d['sourceName']));
return {} as T;
}
throw new Error(`unerwartet: ${type}`);
},
};
return { obs, inhalt, quellen, aufrufe };
}
test('Sound-Quelle: einmal angelegt, in jeder Szene, ein zweiter Lauf aendert nichts', async () => {
const o = obsMitSzenen(['Gameplay', 'Pause']);
const url = 'https://hub/sounds?key=a';
const getan = await soundQuelleEinrichten(o.obs, url);
assert.equal(getan.length, 4);
assert.deepEqual(o.inhalt.get(TON_SZENE), [SOUND_QUELLE]);
assert.deepEqual(o.inhalt.get('Gameplay'), [TON_SZENE]);
assert.deepEqual(o.inhalt.get('Pause'), [TON_SZENE]);
const q = o.quellen.get(SOUND_QUELLE)!;
assert.equal(q['reroute_audio'], true, 'sonst geht der Ton am Stream vorbei');
assert.equal(q['shutdown'], false);
assert.deepEqual(await soundQuelleEinrichten(o.obs, url), []);
assert.equal(o.aufrufe.filter((a) => a.startsWith('Create')).length, 4);
});
test('Sound-Quelle: neue Szene und neuer Schluessel werden nachgezogen', async () => {
const o = obsMitSzenen(['Gameplay']);
await soundQuelleEinrichten(o.obs, 'https://hub/sounds?key=alt');
o.inhalt.set('Just Chatting', []);
const getan = await soundQuelleEinrichten(o.obs, 'https://hub/sounds?key=neu');
assert.deepEqual(getan, [`Adresse von "${SOUND_QUELLE}" erneuert`, 'in "Just Chatting" eingehaengt']);
assert.equal(o.quellen.get(SOUND_QUELLE)!['url'], 'https://hub/sounds?key=neu');
});
test('die eigene Ton-Szene steht nicht in der Szenenauswahl', async () => {
const obs = falschesObs({
GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [
{ sceneName: 'A', sceneIndex: 1 }, { sceneName: TON_SZENE, sceneIndex: 0 }] }),
GetInputList: () => ({ inputs: [] }),
});
assert.deepEqual((await standLesen(obs)).szenen, ['A']);
});
+380
View File
@@ -0,0 +1,380 @@
// Fernsteuerung fuer OBS, Seite des Stream-PCs.
//
// Das Pult am Handy redet mit dem Hub, OBS laeuft hier. Der Hub kommt an
// diesen Rechner nicht heran (fremder Router, keine Portfreigabe), also waehlt
// sich der PC beim Hub ein und haelt die Leitung offen:
//
// GET /fern/befehle SSE, Befehle kommen herein (Hub -> PC)
// POST /fern/stand Szenen, aktuelle Szene, Ton (PC -> Hub)
// POST /fern/antwort ob ein Befehl geklappt hat (PC -> Hub)
//
// Gegenstueck: apps/overlay-server/src/fern.ts.
//
// Einzurichten ist nichts. Beim ersten Start wuerfelt der PC einen Schluessel
// und klopft damit beim Hub an; der antwortet 202, bis im Pult jemand auf
// "Freigeben" tippt. Danach schickt der Hub die Adresse der Sound-Quelle, und
// der PC legt sie in OBS selbst an. `abdulive fern:verbinden` bleibt als Weg
// von Hand.
import { logger } from '#core/logger.ts';
import { forever, sleep } from '#core/retry.ts';
import type { ObsClient } from '#obs/client.ts';
const log = logger('fern');
/** Der Hub schickt alle 15 s einen Herzschlag. Drei verpasste heisst: tot. */
const TOT_NACH_MS = 45_000;
/** So oft fragt ein noch nicht freigegebener PC nach. */
const WARTEN_MS = 10_000;
/**
* Was der PC in OBS anlegt. Die Sound-Quelle sitzt in einer eigenen Szene,
* und diese Szene haengt in jeder anderen: OBS gibt den Ton einer Quelle nur
* aus, wenn sie in der gerade gezeigten Szene liegt. So gibt es die Quelle
* trotzdem nur einmal, und ein Szenenwechsel startet sie nicht neu.
*/
export const TON_SZENE = 'abdulive Ton';
export const SOUND_QUELLE = 'abdulive Sounds';
/** Nach diesen OBS-Ereignissen stimmt der gemeldete Stand nicht mehr. */
const STAND_EREIGNISSE = new Set([
'CurrentProgramSceneChanged', 'SceneListChanged', 'SceneCreated', 'SceneRemoved',
'SceneNameChanged', 'InputMuteStateChanged', 'InputCreated', 'InputRemoved',
'InputNameChanged', 'ReplayBufferStateChanged',
]);
export type Stand = {
obs: boolean;
szenen: string[];
aktuell: string | null;
ton: Array<{ name: string; stumm: boolean }>;
puffer: boolean;
};
export type Befehl =
| { id: string; art: 'szene'; szene: string }
| { id: string; art: 'clip' }
| { id: string; art: 'stumm'; quelle: string; stumm: boolean };
/** Kommt gleich nach dem Verbinden, ohne ID: darauf antwortet niemand. */
export type Einrichten = { art: 'einrichten'; soundsUrl: string };
export function einrichtenAus(roh: unknown): Einrichten | null {
if (!roh || typeof roh !== 'object') return null;
const r = roh as Record<string, unknown>;
const url = r['soundsUrl'];
// Nur eine Adresse auf eine Sound-Seite. Was der Hub hier schickt, landet
// als Browser-Source in OBS - eine beliebige Seite haette dort nichts verloren.
if (r['art'] !== 'einrichten' || typeof url !== 'string' || !/^https?:\/\/[^\s]+\/sounds\?key=[\w-]+$/.test(url)) {
return null;
}
return { art: 'einrichten', soundsUrl: url };
}
/**
* Zerlegt einen SSE-Strom in Nachrichten. Bekommt beliebige Stuecke - eine
* Nachricht kann mitten im Wort geteilt ankommen, und zwei koennen in einem
* Stueck stecken.
*/
export function sseZerleger(): (stueck: string) => string[] {
let rest = '';
return (stueck) => {
rest += stueck.replace(/\r\n?/g, '\n');
const fertig: string[] = [];
let i: number;
while ((i = rest.indexOf('\n\n')) !== -1) {
const block = rest.slice(0, i);
rest = rest.slice(i + 2);
const daten = block.split('\n')
.filter((z) => z.startsWith('data:'))
.map((z) => z.slice(5).replace(/^ /, ''));
if (daten.length) fertig.push(daten.join('\n'));
}
return fertig;
};
}
/** Was vom Hub kommt, pruefen. Unbekanntes wird nicht ausgefuehrt. */
export function befehlAus(roh: unknown): Befehl | null {
if (!roh || typeof roh !== 'object') return null;
const r = roh as Record<string, unknown>;
const id = typeof r['id'] === 'string' ? r['id'] : '';
if (!id) return null;
if (r['art'] === 'szene' && typeof r['szene'] === 'string') return { id, art: 'szene', szene: r['szene'] };
if (r['art'] === 'clip') return { id, art: 'clip' };
if (r['art'] === 'stumm' && typeof r['quelle'] === 'string') {
return { id, art: 'stumm', quelle: r['quelle'], stumm: r['stumm'] === true };
}
return null;
}
/** Szenen, aktuelle Szene, stummschaltbare Eingaenge und der Puffer. */
export async function standLesen(obs: ObsClient): Promise<Stand> {
const leer: Stand = { obs: false, szenen: [], aktuell: null, ton: [], puffer: false };
if (!obs.isConnected()) return leer;
try {
const liste = await obs.request<{
currentProgramSceneName: string;
scenes: Array<{ sceneName: string; sceneIndex: number }>;
}>('GetSceneList');
// OBS zaehlt von unten. Im Fenster steht die mit dem hoechsten Index oben,
// und in dieser Reihenfolge erwartet man sie auch am Handy.
const szenen = [...liste.scenes]
.sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName)
// Die eigene Ton-Szene ist zum Einhaengen da, nicht zum Umschalten.
.filter((s) => s !== TON_SZENE);
const eingaenge = await obs.request<{ inputs: Array<{ inputName: string }> }>('GetInputList');
// Welcher Eingang Ton hat, sagt OBS nicht direkt. Wer auf GetInputMute
// antwortet, hat welchen; Bildquellen lehnen ab.
const stumm = await Promise.allSettled(eingaenge.inputs.map((e) =>
obs.request<{ inputMuted: boolean }>('GetInputMute', { inputName: e.inputName })
.then((r) => ({ name: e.inputName, stumm: r.inputMuted }))));
const ton = stumm.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));
const puffer = await obs.request<{ outputActive: boolean }>('GetReplayBufferStatus')
.then((r) => r.outputActive, () => false);
return { obs: true, szenen, aktuell: liste.currentProgramSceneName || null, ton, puffer };
} catch (err) {
log.debug(`Stand nicht gelesen: ${(err as Error).message}`);
return { ...leer, obs: obs.isConnected() };
}
}
/**
* Legt die Sound-Quelle in OBS an, wenn sie fehlt, und haengt die Ton-Szene
* in jede Szene, in der sie noch nicht steckt. Laeuft beim Verbinden und bei
* jeder neuen Szene; ein zweiter Lauf aendert nichts.
*
* Gibt zurueck, was getan wurde - fuers Protokoll.
*/
export async function soundQuelleEinrichten(obs: ObsClient, url: string): Promise<string[]> {
const getan: string[] = [];
const liste = await obs.request<{ scenes: Array<{ sceneName: string }> }>('GetSceneList');
const namen = liste.scenes.map((s) => s.sceneName);
if (!namen.includes(TON_SZENE)) {
await obs.request('CreateScene', { sceneName: TON_SZENE });
getan.push(`Szene "${TON_SZENE}" angelegt`);
}
const vorhanden = await obs.request<{ inputSettings: Record<string, unknown> }>(
'GetInputSettings', { inputName: SOUND_QUELLE }).catch(() => null);
const einstellungen = {
url, width: 400, height: 120,
// "Audio ueber OBS steuern": ohne das geht der Ton am Mixer vorbei
// direkt auf die Lautsprecher und nie in den Stream.
reroute_audio: true,
// Weiterlaufen, auch wenn gerade nicht sichtbar, und beim Szenenwechsel
// nicht neu laden - sonst bricht jeder Wechsel den laufenden Sound ab.
shutdown: false,
restart_when_active: false,
};
if (!vorhanden) {
await obs.request('CreateInput', {
sceneName: TON_SZENE, inputName: SOUND_QUELLE, inputKind: 'browser_source',
inputSettings: einstellungen, sceneItemEnabled: true,
});
getan.push(`Quelle "${SOUND_QUELLE}" angelegt`);
} else if (vorhanden.inputSettings['url'] !== url) {
// Neuer Overlay-Schluessel auf dem Hub: die alte Adresse ist tot.
await obs.request('SetInputSettings', { inputName: SOUND_QUELLE, inputSettings: { url } });
getan.push(`Adresse von "${SOUND_QUELLE}" erneuert`);
}
for (const szene of namen) {
if (szene === TON_SZENE) continue;
const inhalt = await obs.request<{ sceneItems: Array<{ sourceName: string }> }>(
'GetSceneItemList', { sceneName: szene });
if (inhalt.sceneItems.some((i) => i.sourceName === TON_SZENE)) continue;
await obs.request('CreateSceneItem', { sceneName: szene, sourceName: TON_SZENE, sceneItemEnabled: true });
getan.push(`in "${szene}" eingehaengt`);
}
return getan;
}
export type FernDeps = {
url: string;
key: string;
/** So erscheint der PC bei der Freigabe im Pult. */
name: string;
obs: ObsClient;
/** Den Wiedergabepuffer sichern, wie bei !clip, nur ohne Abklingzeit. */
clip: () => Promise<{ ok: boolean; reason?: string }>;
};
export type FernAnschluss = {
run: (signal: AbortSignal) => Promise<void>;
/** Nach einem OBS-Ereignis rufen. Sammelt kurz, dann geht ein Stand raus. */
obsEreignis: (type: string) => void;
/** OBS verbunden oder getrennt. */
obsWechsel: () => void;
verbunden: () => boolean;
};
export function createFernanschluss(deps: FernDeps): FernAnschluss {
const basis = deps.url.replace(/\/+$/, '');
// Der Rechnername steht nur in der Freigabe im Pult. Nur ASCII: ein Umlaut
// im Namen machte den ganzen Kopf ungueltig, und fetch wirft.
const kopf = {
authorization: `Bearer ${deps.key}`,
'x-abdulive-rechner': deps.name.replace(/[^\x20-\x7e]/g, '?').slice(0, 40),
};
let verbunden = false;
let sammler: NodeJS.Timeout | null = null;
/** Kommt vom Hub nach dem Verbinden. Bis dahin wird in OBS nichts angelegt. */
let soundsUrl: string | null = null;
let richtetEin = false;
async function post(pfad: string, body: unknown): Promise<void> {
try {
const res = await fetch(`${basis}${pfad}`, {
method: 'POST',
headers: { ...kopf, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) log.warn(`${pfad}: Hub antwortet ${res.status}`);
} catch (err) {
log.debug(`${pfad} nicht zugestellt: ${(err as Error).message}`);
}
}
const standSenden = async (): Promise<void> => {
if (!verbunden) return;
await post('/fern/stand', await standLesen(deps.obs));
};
const spaeterSenden = (): void => {
// OBS feuert beim Szenenwechsel mehrere Ereignisse kurz hintereinander.
// Ein Stand danach reicht.
if (sammler) clearTimeout(sammler);
sammler = setTimeout(() => { sammler = null; void standSenden(); }, 300);
};
/** Sound-Quelle in OBS anlegen bzw. pruefen. Nie zweimal gleichzeitig. */
const einrichten = async (): Promise<void> => {
if (!soundsUrl || !deps.obs.isConnected() || richtetEin) return;
richtetEin = true;
try {
const getan = await soundQuelleEinrichten(deps.obs, soundsUrl);
if (getan.length) log.info(`OBS eingerichtet: ${getan.join(', ')}`);
if (getan.length) spaeterSenden();
} catch (err) {
log.warn(`Sound-Quelle in OBS nicht eingerichtet: ${(err as Error).message}`);
} finally {
richtetEin = false;
}
};
async function ausfuehren(b: Befehl): Promise<{ ok: boolean; text: string }> {
if (b.art === 'clip') {
const r = await deps.clip();
return r.ok ? { ok: true, text: 'Clip wird gesichert.' }
: { ok: false, text: r.reason ?? 'Clip ging nicht.' };
}
if (!deps.obs.isConnected()) return { ok: false, text: 'OBS ist auf dem Stream-PC nicht verbunden.' };
try {
if (b.art === 'szene') {
await deps.obs.request('SetCurrentProgramScene', { sceneName: b.szene });
return { ok: true, text: `Szene: ${b.szene}` };
}
await deps.obs.request('SetInputMute', { inputName: b.quelle, inputMuted: b.stumm });
return { ok: true, text: `${b.quelle} ${b.stumm ? 'stumm' : 'wieder an'}` };
} catch (err) {
return { ok: false, text: (err as Error).message };
}
}
async function einmal(signal: AbortSignal): Promise<void> {
const wachhund = new AbortController();
const beides = AbortSignal.any([signal, wachhund.signal]);
let zuletzt = Date.now();
const takt = setInterval(() => {
if (Date.now() - zuletzt > TOT_NACH_MS) {
log.warn('Vom Hub nichts mehr gehoert, verbinde neu');
wachhund.abort();
}
}, 10_000);
try {
let res: Response;
let gemeldet = false;
// Solange der Hub 202 sagt, ist der PC noch nicht freigegeben. Dann im
// festen Takt nachfragen - die Freigabe soll ein paar Sekunden nach dem
// Tipp im Pult greifen, nicht erst nach einer Minute Rueckzug.
for (;;) {
res = await fetch(`${basis}/fern/befehle`, {
headers: { ...kopf, accept: 'text/event-stream' }, signal: beides,
});
if (res.status !== 202) break;
await res.body?.cancel().catch(() => {});
if (!gemeldet) {
log.info(`Wartet auf Freigabe: im Pult (${basis}/pult) unter Szenen auf „Freigeben“ tippen`);
gemeldet = true;
}
zuletzt = Date.now();
await sleep(WARTEN_MS, beides);
}
if (res.status === 403) {
log.error('Der Hub lehnt den Schluessel ab. Neu einrichten mit dem Befehl ' +
'von der Uebersichtsseite: abdulive fern:verbinden ...');
// Nicht im Sekundentakt anklopfen, der Schluessel wird nicht von allein richtig.
await sleep(5 * 60_000, signal).catch(() => {});
throw new Error('Schluessel abgelehnt');
}
if (!res.ok || !res.body) throw new Error(`Hub antwortet ${res.status}`);
verbunden = true;
log.info(`Mit dem Hub verbunden (${basis}) - das Pult kann OBS jetzt steuern`);
void standSenden();
const zerleger = sseZerleger();
const decoder = new TextDecoder();
for await (const stueck of res.body as unknown as AsyncIterable<Uint8Array>) {
zuletzt = Date.now();
for (const daten of zerleger(decoder.decode(stueck, { stream: true }))) {
let roh: unknown;
try { roh = JSON.parse(daten); } catch { continue; }
const e = einrichtenAus(roh);
if (e) { soundsUrl = e.soundsUrl; void einrichten(); continue; }
const b = befehlAus(roh);
if (!b) continue;
void ausfuehren(b).then((a) => {
log.info(`Pult: ${b.art} -> ${a.ok ? 'ok' : 'abgelehnt'} (${a.text})`);
return post('/fern/antwort', { id: b.id, ...a });
}).then(spaeterSenden);
}
}
throw new Error('Hub hat die Leitung geschlossen');
} catch (err) {
if (!signal.aborted) log.debug(`Leitung zum Hub weg: ${(err as Error).message}`);
throw err;
} finally {
clearInterval(takt);
if (verbunden) log.info('Leitung zum Hub getrennt');
verbunden = false;
}
}
return {
run: (signal) => forever(einmal, {
signal, baseMs: 2000, maxMs: 60_000,
onWait: (ms, n) => {
const zeile = `Hub nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`;
if (n >= 3) log.warn(zeile); else log.debug(zeile);
},
}),
obsEreignis: (type) => {
if (STAND_EREIGNISSE.has(type)) spaeterSenden();
// Eine neue Szene bekommt die Ton-Szene gleich mit, sonst ist dort
// das Soundboard stumm.
if (type === 'SceneCreated') void einrichten();
},
obsWechsel: () => { spaeterSenden(); void einrichten(); },
verbunden: () => verbunden,
};
}
+40 -3
View File
@@ -15,10 +15,13 @@ import { createObs } from '#obs/client.ts';
import { isWindows } from '#core/platform.ts'; import { isWindows } from '#core/platform.ts';
import { helix } from '#twitch/helix.ts'; import { helix } from '#twitch/helix.ts';
import { createIrc } from '#twitch/irc.ts'; import { createIrc } from '#twitch/irc.ts';
import { readSecrets } from '#twitch/secrets.ts'; import { readSecrets, updateSecrets } from '#twitch/secrets.ts';
import crypto from 'node:crypto';
import os from 'node:os';
import { streamWechsel } from '#twitch/stream-state.ts'; import { streamWechsel } from '#twitch/stream-state.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createClipper } from './clips.ts'; import { createClipper } from './clips.ts';
import { createFernanschluss } from './fern.ts';
import { createRenderer } from './render.ts'; import { createRenderer } from './render.ts';
import { createSpikeDetector } from './spike.ts'; import { createSpikeDetector } from './spike.ts';
import { startServer } from './server.ts'; import { startServer } from './server.ts';
@@ -62,8 +65,9 @@ export async function runIngest(): Promise<void> {
cfg.obs.url, cfg.obs.url,
() => readSecrets().obs?.password, () => readSecrets().obs?.password,
{ {
onIdentified: () => { state.obsConnected = true; }, onIdentified: () => { state.obsConnected = true; fern?.obsWechsel(); },
onClosed: () => { state.obsConnected = false; }, onClosed: () => { state.obsConnected = false; fern?.obsWechsel(); },
onEvent: (type) => fern?.obsEreignis(type),
onSceneChanged: (scene) => { onSceneChanged: (scene) => {
state.obsScene = scene; state.obsScene = scene;
repo.event('obs.scene_changed', { scene }, state.streamId); repo.event('obs.scene_changed', { scene }, state.streamId);
@@ -114,6 +118,22 @@ export async function runIngest(): Promise<void> {
onRawReady: (job) => renderer.enqueue(job), onRawReady: (job) => renderer.enqueue(job),
}); });
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Einzurichten ist
// nichts: beim ersten Start wird ein Schluessel gewuerfelt, der PC meldet
// sich beim Hub, und im Pult erscheint "Freigeben".
const fernZugang = fernZugangHolen(cfg.fern.url);
const fern = fernZugang
? createFernanschluss({
url: fernZugang.url,
key: fernZugang.key,
name: os.hostname(),
obs,
clip: () => clipper.trigger('manual', { login: 'Pult' }),
})
: null;
if (fern) void fern.run(abort.signal);
else log.info('Pult-Fernsteuerung aus (fern.url ist leer)');
// ---- Go-Live-Ankuendigung auf Discord // ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({ const announcer = createAnnouncer({
cfg: { cfg: {
@@ -138,6 +158,7 @@ export async function runIngest(): Promise<void> {
goLive: announcer.lastResult() ?? state.goLive, goLive: announcer.lastResult() ?? state.goLive,
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
clipsWaitingForFile: clipper.pendingCount(), clipsWaitingForFile: clipper.pendingCount(),
pult: fern ? (fern.verbunden() ? 'verbunden' : 'getrennt') : 'nicht eingerichtet',
renderQueue: renderer.pending(), renderQueue: renderer.pending(),
rendering: renderer.currentJob(), rendering: renderer.currentJob(),
note: state.chatAnonymous note: state.chatAnonymous
@@ -204,6 +225,22 @@ export async function runIngest(): Promise<void> {
await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve())); await new Promise<void>((resolve) => abort.signal.addEventListener('abort', () => resolve()));
} }
/**
* Adresse und Schluessel fuer die Leitung zum Hub. Was mit
* `abdulive fern:verbinden` von Hand eingetragen ist, gewinnt. Sonst wird
* einmal gewuerfelt und gemerkt - der Hub kennt den Schluessel erst nach der
* Freigabe im Pult, also darf er sich nicht bei jedem Start aendern.
*/
function fernZugangHolen(vorgabeUrl: string): { url: string; key: string } | null {
const da = readSecrets().fern;
if (da?.url && da.key) return da;
if (!vorgabeUrl) return null;
const neu = { url: vorgabeUrl, key: crypto.randomBytes(24).toString('base64url') };
updateSecrets((s) => { s.fern = neu; });
log.info(`Neuer Schluessel fuer das Pult gewuerfelt - meldet sich bei ${vorgabeUrl}`);
return neu;
}
type State = { type State = {
streamId: string | null; broadcasterId: string | null; streamId: string | null; broadcasterId: string | null;
lastSeenLive: number; viewers: number; goLive: string; lastError: string | null; lastSeenLive: number; viewers: number; goLive: string; lastError: string | null;
+1 -1
View File
@@ -21,7 +21,7 @@ function msg(text: string, userId: string, advanceMs = 0): ChatMessage {
id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId, id: `m${clock}-${userId}`, ts: new Date(clock).toISOString(), userId,
login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [], login: `u${userId}`, displayName: `U${userId}`, text, emotes: [], gifs: [],
isFirst: false, isReturning: false, isCommand: text.startsWith('!'), isFirst: false, isReturning: false, isCommand: text.startsWith('!'),
isMod: false, isSub: false, isBroadcaster: false, bits: 0, color: null, isMod: false, isSub: false, isBroadcaster: false, badges: [], bits: 0, color: null,
}; };
} }
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/loyalty
Punktesystem mit Ledger und Shop mit echten Effekten. Modul im overlay-server.
Spezifikation: [docs/projects/loyalty-shop.md](../../docs/projects/loyalty-shop.md)
+6
View File
@@ -10,8 +10,14 @@ offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
/callback OAuth-Rückruf von Twitch offen, `state` schützt /callback OAuth-Rückruf von Twitch offen, `state` schützt
/alerts … die Browser-Sources Schlüssel in der URL /alerts … die Browser-Sources Schlüssel in der URL
/events der Ereignisstrom dahinter Schlüssel in der URL /events der Ereignisstrom dahinter Schlüssel in der URL
/pult Steuerpult fürs Handy (PWA) Passwort
/soundboard/… Sounds hochladen, ändern, spielen Passwort (Datei: Schlüssel)
/fern/… Leitung zum Stream-PC für OBS eigener Schlüssel im Kopf
``` ```
Der Fern-Schlüssel ist der dritte Zugang und der einzige, der etwas schaltet:
er steht deshalb nie in einer Adresse, sondern in `authorization: Bearer …`.
## Server-Sent Events statt WebSocket ## Server-Sent Events statt WebSocket
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen [`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
+11
View File
@@ -66,3 +66,14 @@ test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
// beenden lässt - dafür gibt es den Passwortwechsel. // beenden lässt - dafür gibt es den Passwortwechsel.
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN)); assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
}); });
test('der Schluessel des Stream-PCs kommt aus dem Bearer-Kopf', async () => {
// Hier fehlten beim ersten Wurf die Backslashes in der Regex - jeder
// richtige Schluessel wurde abgelehnt, und nichts im Log sah danach aus.
const { bearer } = await import('./auth.ts');
assert.equal(bearer('Bearer 33qg61wQf_ad-x9v'), '33qg61wQf_ad-x9v');
assert.equal(bearer('Bearer abc'), 'abc');
assert.equal(bearer('Basic abc'), null);
assert.equal(bearer('Bearer'), null);
assert.equal(bearer(undefined), null);
});
+34
View File
@@ -35,6 +35,12 @@ export type Zugang = {
abmelden: () => string; abmelden: () => string;
overlayKey: () => string; overlayKey: () => string;
neuerOverlayKey: () => string; neuerOverlayKey: () => string;
/** Schluessel des Stream-PCs, im Kopf `authorization: Bearer ...`. */
darfFern: (req: http.IncomingMessage) => boolean;
fernKey: () => string;
neuerFernKey: () => string;
/** Den Schluessel eines im Pult freigegebenen PCs eintragen. */
fernKeySetzen: (key: string) => void;
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */ /** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
hatPasswort: () => boolean; hatPasswort: () => boolean;
}; };
@@ -88,6 +94,11 @@ function signatur(key: Buffer, passwordHash: string, bis: string): string {
.digest('base64url'); .digest('base64url');
} }
/** Der Schluessel aus `authorization: Bearer ...`, sonst `null`. */
export function bearer(kopf: string | undefined): string | null {
return /^Bearer\s+(\S+)$/.exec(kopf ?? '')?.[1] ?? null;
}
function cookies(req: http.IncomingMessage): Record<string, string> { function cookies(req: http.IncomingMessage): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const teil of (req.headers.cookie ?? '').split(';')) { for (const teil of (req.headers.cookie ?? '').split(';')) {
@@ -114,6 +125,14 @@ export function createZugang(): Zugang {
return neu; return neu;
} }
function fernKey(): string {
const vorhanden = store().fernKey;
if (vorhanden) return vorhanden;
const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
return neu;
}
/** /**
* Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird * Schluessel zum Signieren der Sitzungen. Liegt neben dem Passwort, wird
* einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren, * einmal gewuerfelt und ueberlebt damit jeden Neustart. Geht er verloren,
@@ -161,6 +180,21 @@ export function createZugang(): Zugang {
abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, abmelden: () => `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
overlayKey: key, overlayKey: key,
darfFern(req) {
const gegeben = bearer(req.headers.authorization);
return Boolean(gegeben && gleich(gegeben, fernKey()));
},
fernKey,
fernKeySetzen(k) {
updateSecrets((s) => { s.web = { ...s.web, fernKey: k }; });
},
neuerFernKey() {
const neu = crypto.randomBytes(24).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
log.info('Schluessel fuer den Stream-PC neu gewuerfelt - dort neu eintragen');
return neu;
},
neuerOverlayKey() { neuerOverlayKey() {
const neu = crypto.randomBytes(18).toString('base64url'); const neu = crypto.randomBytes(18).toString('base64url');
updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; }); updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; });
+9 -1
View File
@@ -10,7 +10,7 @@ function nachricht(over: Partial<ChatEvent> = {}): ChatEvent {
return { return {
kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin', kind: 'chat', id: 'm1', userId: 'u1', login: 'kevin', name: 'Kevin',
text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false, text: 'moin', color: null, emotes: [], gifs: [], first: false, mod: false,
sub: false, broadcaster: false, bits: 0, sub: false, broadcaster: false, badges: [], bits: 0,
...over, ...over,
}; };
} }
@@ -108,3 +108,11 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
assert.equal(n, 1); assert.equal(n, 1);
assert.equal(bus.clients(), 0); assert.equal(bus.clients(), 0);
}); });
test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => {
const bus = createBus();
assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 0, gemacht: 0 });
bus.publish({ kind: 'liegestuetze', offen: 20, gemacht: 0, dazu: 10, name: 'Kevin' });
bus.publish({ kind: 'liegestuetze', offen: 15, gemacht: 5 });
assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 15, gemacht: 5 });
});
+72 -2
View File
@@ -29,7 +29,14 @@ export type ChatEvent = {
mod: boolean; mod: boolean;
sub: boolean; sub: boolean;
broadcaster: boolean; broadcaster: boolean;
/** Abzeichen als fertige Bilder, aufgeloest im Hub (#twitch/badges.ts). */
badges: Array<{ url: string; title: string }>;
bits: number; bits: number;
/**
* Sendezeit laut Twitch. Fuer die Uhrzeit im Pult: ohne sie stuende nach
* dem Neuverbinden an allen Nachrichten aus dem Schnappschuss dieselbe.
*/
ts?: string;
}; };
export type AlertKind = export type AlertKind =
@@ -65,6 +72,38 @@ export type StreamEvent = {
startedAt: string | null; startedAt: string | null;
}; };
/**
* Neuer Stand der Liegestuetze: offen und in diesem Stream gemacht. `dazu`
* und `name` nur bei einer Einloesung - eine Korrektur von Hand soll nicht
* aussehen wie eine.
*/
export type LiegestuetzeEvent = {
kind: 'liegestuetze';
offen: number;
gemacht: number;
dazu?: number;
name?: string;
};
/**
* Was der Stream-PC ueber OBS meldet. Kommt ueber die Fernsteuerung
* (fern.ts) herein und steht im Schnappschuss, damit das Pult nach dem
* Neuverbinden sofort die Szenen kennt.
*/
export type ObsStandEvent = {
kind: 'obs';
/** Ist der Stream-PC ueberhaupt mit dem Hub verbunden? */
pc: boolean;
/** Und hat er OBS erreicht? */
obs: boolean;
szenen: string[];
aktuell: string | null;
/** Toneingaenge, die sich stummschalten lassen. */
ton: Array<{ name: string; stumm: boolean }>;
/** Laeuft der Wiedergabepuffer? Ohne ihn gibt es keinen Clip. */
puffer: boolean;
};
export type OverlayEvent = export type OverlayEvent =
| ChatEvent | ChatEvent
| AlertEvent | AlertEvent
@@ -88,10 +127,33 @@ export type OverlayEvent =
} }
/** Alles sofort still. Fuer den Panikschalter. */ /** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' } | { kind: 'still' }
| LiegestuetzeEvent
| { kind: 'scene'; scene: string } | { kind: 'scene'; scene: string }
/**
* Ein Sound vom Pult. Wie beim Vorlesen geht nur die ID hinaus; die Datei
* holt sich die Seite /sounds einzeln, damit Chat und Statusleiste keine
* Megabytes mitschleppen.
*/
| { kind: 'sound'; id: string; name: string; laut: number }
/** Alle laufenden Sounds abbrechen. Vorlesen bleibt davon unberuehrt. */
| { kind: 'sound-stopp' }
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
| { kind: 'sounds-geaendert' }
/**
* Ein Stream-PC wartet auf Freigabe, oder die Liste hat sich geaendert.
* Ohne Inhalt: wer und von wo, holt sich das Pult mit Anmeldung - der
* Ereignisstrom geht auch an die Browser-Sources.
*/
| { kind: 'fern-anfrage' }
| ObsStandEvent
| { kind: 'clear'; userId?: string; messageId?: string } | { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number } | { kind: 'beat'; t: number }
| { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] }; | {
kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
/** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
liegestuetze: { offen: number; gemacht: number };
obs: ObsStandEvent;
};
/** /**
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr * So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
@@ -118,6 +180,10 @@ export function createBus(): Bus {
let stream: StreamEvent = { let stream: StreamEvent = {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null, kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
}; };
let liegestuetze = { offen: 0, gemacht: 0 };
let obs: ObsStandEvent = {
kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
};
return { return {
publish(ev) { publish(ev) {
@@ -131,6 +197,10 @@ export function createBus(): Bus {
if (alerts.length > KEEP_ALERTS) alerts.shift(); if (alerts.length > KEEP_ALERTS) alerts.shift();
} else if (ev.kind === 'stream') { } else if (ev.kind === 'stream') {
stream = ev; stream = ev;
} else if (ev.kind === 'liegestuetze') {
liegestuetze = { offen: ev.offen, gemacht: ev.gemacht };
} else if (ev.kind === 'obs') {
obs = ev;
} else if (ev.kind === 'clear') { } else if (ev.kind === 'clear') {
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der // Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
// naechste Neustart der Browser-Source es zurueck ins Bild. // naechste Neustart der Browser-Source es zurueck ins Bild.
@@ -153,7 +223,7 @@ export function createBus(): Bus {
return () => { listeners.delete(fn); }; return () => { listeners.delete(fn); };
}, },
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }), snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze, obs }),
clients: () => listeners.size, clients: () => listeners.size,
}; };
} }
+152
View File
@@ -0,0 +1,152 @@
// Die Leitung zum Stream-PC, Hub-Seite. Geprueft wird ohne Netz: die
// SSE-Antwort ist ein nachgebautes Objekt, das mitschreibt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type http from 'node:http';
import { createBus, type ObsStandEvent } from './bus.ts';
import { createFernsteuerung, standAus } from './fern.ts';
import { befehlAus, sichererPfad } from './server.ts';
function leitung() {
const req = new EventEmitter();
const res = Object.assign(new EventEmitter(), {
geschrieben: [] as string[],
writeHead() { return this; },
write(s: string) { this.geschrieben.push(s); return true; },
end() { req.emit('close'); },
});
const befehle = (): Array<Record<string, unknown>> => res.geschrieben
.filter((s) => s.startsWith('data: '))
.map((s) => JSON.parse(s.slice(6)) as Record<string, unknown>)
.filter((b) => b['art'] !== 'beat');
return {
req: req as unknown as http.IncomingMessage,
res: res as unknown as http.ServerResponse,
befehle,
trennen: () => req.emit('close'),
};
}
function obsStand(bus: ReturnType<typeof createBus>): ObsStandEvent {
const s = bus.snapshot();
if (s.kind !== 'state') throw new Error('kein Schnappschuss');
return s.obs;
}
test('ohne Stream-PC sagt ein Befehl ehrlich, dass es nicht ging', async () => {
const fern = createFernsteuerung(createBus());
const a = await fern.senden({ art: 'szene', szene: 'Pause' });
assert.equal(a.ok, false);
assert.match(a.text, /nicht verbunden/);
});
test('Befehl geht raus, Antwort kommt zurueck', async () => {
const bus = createBus();
const fern = createFernsteuerung(bus);
const l = leitung();
fern.annehmen(l.req, l.res);
assert.equal(obsStand(bus).pc, true);
const warten = fern.senden({ art: 'szene', szene: 'Just Chatting' });
const [b] = l.befehle();
assert.equal(b?.['art'], 'szene');
assert.equal(b?.['szene'], 'Just Chatting');
fern.antwort({ id: b!['id'], ok: true, text: 'Szene: Just Chatting' });
assert.deepEqual(await warten, { ok: true, text: 'Szene: Just Chatting' });
});
test('reisst die Leitung ab, wartet niemand weiter', async () => {
const bus = createBus();
const fern = createFernsteuerung(bus);
const l = leitung();
fern.annehmen(l.req, l.res);
fern.melden({ obs: true, szenen: ['A'], aktuell: 'A', ton: [], puffer: true });
assert.deepEqual(obsStand(bus).szenen, ['A']);
const warten = fern.senden({ art: 'clip' });
l.trennen();
const a = await warten;
assert.equal(a.ok, false);
assert.equal(fern.verbunden(), false);
// Alte Szenen stehen sonst als Knoepfe da, die nichts mehr tun.
assert.deepEqual(obsStand(bus), { kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
});
test('ein zweiter PC ersetzt den ersten', () => {
const bus = createBus();
const fern = createFernsteuerung(bus);
const alt = leitung();
const neu = leitung();
fern.annehmen(alt.req, alt.res);
fern.annehmen(neu.req, neu.res);
// Das Schliessen der alten Leitung darf die neue nicht als getrennt melden.
assert.equal(fern.verbunden(), true);
assert.equal(obsStand(bus).pc, true);
void fern.senden({ art: 'clip' });
assert.equal(neu.befehle().length, 1);
assert.equal(alt.befehle().length, 0);
});
test('der Stand vom PC wird auf die erwartete Form gebracht', () => {
assert.deepEqual(standAus({
obs: true, szenen: ['A', 7, 'B'], aktuell: 'A',
ton: [{ name: 'Mikro', stumm: true }, { name: 3 }, null], puffer: 'ja',
}), { obs: true, szenen: ['A', 'B'], aktuell: 'A', ton: [{ name: 'Mikro', stumm: true }], puffer: false });
assert.deepEqual(standAus('quatsch'), { obs: false, szenen: [], aktuell: null, ton: [], puffer: false });
});
test('vom Pult kommen nur bekannte Befehle durch', () => {
const f = (o: Record<string, string>): URLSearchParams => new URLSearchParams(o);
assert.deepEqual(befehlAus(f({ was: 'szene', szene: 'Pause' })), { art: 'szene', szene: 'Pause' });
assert.deepEqual(befehlAus(f({ was: 'stumm', quelle: 'Mikro', stumm: '1' })),
{ art: 'stumm', quelle: 'Mikro', stumm: true });
assert.deepEqual(befehlAus(f({ was: 'clip' })), { art: 'clip' });
assert.equal(befehlAus(f({ was: 'szene' })), null);
assert.equal(befehlAus(f({ was: 'StopStream' })), null);
});
test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
assert.equal(sichererPfad('/pult'), '/pult');
assert.equal(sichererPfad('https://boese.example'), '/');
assert.equal(sichererPfad('//boese.example'), '/');
assert.equal(sichererPfad(null), '/');
});
test('ein unbekannter PC wartet auf Freigabe und bekommt dann seinen Schluessel eingetragen', () => {
const bus = createBus();
const ereignisse: string[] = [];
bus.subscribe((e) => ereignisse.push(e.kind));
const fern = createFernsteuerung(bus);
const key = 'k'.repeat(32);
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
const [a, ...rest] = fern.offeneAnfragen();
assert.equal(rest.length, 0, 'derselbe PC fragt alle paar Sekunden, das ist eine Anfrage');
assert.equal(a?.name, 'ABDU-PC');
assert.equal('key' in (a as object), false, 'der Schluessel geht nie ans Pult');
assert.ok(ereignisse.includes('fern-anfrage'));
assert.equal(fern.freigeben(a!.id), key);
assert.deepEqual(fern.offeneAnfragen(), []);
assert.equal(fern.freigeben(a!.id), null, 'zweimal freigeben geht nicht');
});
test('mehr als drei fremde Anfragen verdraengen die aeltesten', () => {
const fern = createFernsteuerung(createBus());
for (let i = 0; i < 5; i++) fern.anfragen(`schluessel-${i}`.padEnd(30, 'x'), `PC${i}`, '');
assert.deepEqual(fern.offeneAnfragen().map((a) => a.name).sort(), ['PC2', 'PC3', 'PC4']);
});
test('beim Verbinden erfaehrt der PC die Adresse der Sound-Quelle', () => {
const fern = createFernsteuerung(createBus(), {
willkommen: () => ({ soundsUrl: 'https://hub.example/sounds?key=abc' }),
});
const l = leitung();
fern.annehmen(l.req, l.res);
const [hallo] = l.befehle();
assert.deepEqual(hallo, { art: 'einrichten', soundsUrl: 'https://hub.example/sounds?key=abc' });
});
+238
View File
@@ -0,0 +1,238 @@
// Fernsteuerung fuer OBS, Hub-Seite.
//
// OBS laeuft auf dem Stream-PC, das Pult am Handy spricht mit dem Hub. Der
// Hub kommt an den Stream-PC nicht heran - der steht hinter einem fremden
// Router. Also waehlt sich der PC beim Hub ein, nicht umgekehrt:
//
// Handy --POST /pult/obs--> Hub --SSE /fern/befehle--> Stream-PC --> OBS
// Handy <--SSE /events---- Hub <--POST /fern/stand---- Stream-PC
//
// Dieselbe Bauform wie bei den Browser-Sources (SSE statt WebSocket, siehe
// README), nur mit einem eigenen Schluessel: der fuer OBS darf nur lesen,
// dieser hier schaltet Szenen um.
//
// Jeder Befehl wartet auf eine Antwort vom PC. Ein Knopf, der "erledigt"
// meldet, waehrend OBS gar nicht erreichbar war, ist am Handy schlimmer als
// einer, der ehrlich sagt, dass es nicht ging.
import type http from 'node:http';
import crypto from 'node:crypto';
import { logger } from '#core/logger.ts';
import type { Bus, ObsStandEvent } from './bus.ts';
const log = logger('overlay:fern');
const HEARTBEAT_MS = 15_000;
const ANTWORT_MS = 6_000;
export type FernBefehl =
| { art: 'szene'; szene: string }
| { art: 'clip' }
| { art: 'stumm'; quelle: string; stumm: boolean };
export type FernAntwort = { ok: boolean; text: string };
/**
* Ein Stream-PC, der sich mit einem noch unbekannten Schluessel meldet. Er
* wartet, bis jemand im Pult auf "Freigeben" tippt - damit Abdu keinen Befehl
* abtippen muss. Der Schluessel selbst geht nie ans Pult zurueck.
*/
export type FernAnfrage = { id: string; name: string; adresse: string; seit: string };
/** So viele Anfragen gleichzeitig, so lange gueltig. Mehr ist Laerm oder ein Angriff. */
const MAX_ANFRAGEN = 3;
const ANFRAGE_MS = 10 * 60_000;
export type Fernsteuerung = {
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
/** Ein unbekannter Schluessel klopft an. */
anfragen: (key: string, name: string, adresse: string) => void;
offeneAnfragen: () => FernAnfrage[];
/** Gibt den Schluessel der Anfrage zurueck, damit er eingetragen werden kann. */
freigeben: (id: string) => string | null;
ablehnen: (id: string) => boolean;
/** Leitung schliessen, z. B. nachdem der Schluessel neu gewuerfelt wurde. */
trennen: () => void;
/** Stand von OBS, wie ihn der PC meldet. */
melden: (roh: unknown) => void;
antwort: (roh: unknown) => void;
senden: (b: FernBefehl) => Promise<FernAntwort>;
verbunden: () => boolean;
};
const LEER: Omit<ObsStandEvent, 'kind'> = {
pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
};
/** Was vom PC kommt, auf die erwartete Form bringen. Fremdes faellt weg. */
export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
const text = (v: unknown): string | null => (typeof v === 'string' && v.length <= 200 ? v : null);
const szenen = Array.isArray(r['szenen'])
? r['szenen'].map(text).filter((s): s is string => s !== null).slice(0, 100) : [];
const ton = Array.isArray(r['ton'])
? r['ton'].flatMap((t) => {
const name = text((t as Record<string, unknown>)?.['name']);
return name === null ? [] : [{ name, stumm: (t as Record<string, unknown>)['stumm'] === true }];
}).slice(0, 50) : [];
return {
obs: r['obs'] === true,
szenen,
aktuell: text(r['aktuell']),
ton,
puffer: r['puffer'] === true,
};
}
export type FernOptionen = {
/**
* Was der PC gleich nach dem Verbinden erfaehrt, z. B. die Adresse der
* Sound-Quelle mit Schluessel. Damit richtet er OBS selbst ein.
*/
willkommen?: () => Record<string, unknown> | null;
};
export function createFernsteuerung(bus: Bus, fo: FernOptionen = {}): Fernsteuerung {
let leitung: http.ServerResponse | null = null;
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
const anfragen = new Map<string, FernAnfrage & { key: string; zuletzt: number }>();
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
bus.publish({ kind: 'obs', ...stand });
};
const aufraeumen = (): void => {
const jetzt = Date.now();
for (const [id, a] of anfragen) if (jetzt - a.zuletzt > ANFRAGE_MS) anfragen.delete(id);
};
return {
anfragen(key, name, adresse) {
aufraeumen();
// Derselbe PC klopft alle paar Sekunden erneut an. Das ist dieselbe
// Anfrage, keine neue - sonst stuende er nach einer Minute zehnmal da.
for (const a of anfragen.values()) {
if (a.key === key) { a.zuletzt = Date.now(); return; }
}
if (anfragen.size >= MAX_ANFRAGEN) {
const aelteste = [...anfragen.values()].sort((x, y) => x.zuletzt - y.zuletzt)[0];
if (aelteste) anfragen.delete(aelteste.id);
}
const id = crypto.randomBytes(6).toString('hex');
const sauber = (s: string, n: number): string =>
s.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) || 'unbekannt';
anfragen.set(id, {
id, key, name: sauber(name, 40), adresse: sauber(adresse, 60),
seit: new Date().toISOString(), zuletzt: Date.now(),
});
log.info(`Stream-PC "${sauber(name, 40)}" (${sauber(adresse, 60)}) wartet auf Freigabe im Pult`);
bus.publish({ kind: 'fern-anfrage' });
},
offeneAnfragen() {
aufraeumen();
return [...anfragen.values()].map(({ id, name, adresse, seit }) => ({ id, name, adresse, seit }));
},
freigeben(id) {
aufraeumen();
const a = anfragen.get(id);
if (!a) return null;
// Es gibt nur einen Stream-PC. Wer freigibt, verwirft die anderen.
anfragen.clear();
log.info(`Stream-PC "${a.name}" freigegeben`);
bus.publish({ kind: 'fern-anfrage' });
return a.key;
},
ablehnen(id) {
const weg = anfragen.delete(id);
if (weg) bus.publish({ kind: 'fern-anfrage' });
return weg;
},
trennen() {
leitung?.end();
},
annehmen(req, res) {
if (leitung) {
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
// Der neuere gewinnt, meist ist der alte ohnehin eine tote Leitung.
log.info('Stream-PC hat sich neu verbunden, alte Leitung geschlossen');
leitung.end();
}
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no',
});
res.write('retry: 2000\n\n');
leitung = res;
log.info('Stream-PC verbunden');
veroeffentlichen({ ...LEER, pc: true });
const hallo = fo.willkommen?.();
if (hallo) res.write(`data: ${JSON.stringify({ art: 'einrichten', ...hallo })}\n\n`);
const beat = setInterval(
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
HEARTBEAT_MS);
beat.unref();
const ende = (): void => {
clearInterval(beat);
if (leitung !== res) return; // schon durch eine neuere ersetzt
leitung = null;
log.info('Stream-PC getrennt');
veroeffentlichen(LEER);
for (const [id, o] of offen) {
clearTimeout(o.timer);
o.fertig({ ok: false, text: 'Verbindung zum Stream-PC abgerissen.' });
offen.delete(id);
}
};
req.on('close', ende);
res.on('error', ende);
},
melden(roh) {
if (!leitung) return;
veroeffentlichen({ pc: true, ...standAus(roh) });
},
antwort(roh) {
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
const id = typeof r['id'] === 'string' ? r['id'] : '';
const o = offen.get(id);
if (!o) return;
offen.delete(id);
clearTimeout(o.timer);
o.fertig({
ok: r['ok'] === true,
text: typeof r['text'] === 'string' ? r['text'].slice(0, 300) : '',
});
},
senden(b) {
const res = leitung;
if (!res) {
return Promise.resolve({
ok: false, text: 'Der Stream-PC ist nicht verbunden. Läuft dort abdulive?',
});
}
const id = crypto.randomBytes(6).toString('hex');
return new Promise<FernAntwort>((fertig) => {
const timer = setTimeout(() => {
offen.delete(id);
fertig({ ok: false, text: 'Der Stream-PC hat nicht geantwortet.' });
}, ANTWORT_MS);
offen.set(id, { fertig, timer });
res.write(`data: ${JSON.stringify({ id, ...b })}\n\n`);
});
},
verbunden: () => leitung !== null,
};
}
+475 -22
View File
@@ -8,6 +8,8 @@
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt) // /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
// /alerts ... die Browser-Sources (Schluessel) // /alerts ... die Browser-Sources (Schluessel)
// /events der SSE-Strom dahinter (Schluessel) // /events der SSE-Strom dahinter (Schluessel)
// /pult Steuerpult fuers Handy (PWA) (Passwort)
// /fern/... Leitung zum Stream-PC fuer OBS (eigener Schluessel)
// //
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit // Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit // der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
@@ -18,11 +20,14 @@ import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { paths } from '#core/config.ts';
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts'; import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
import { createZugang, type Zugang } from './auth.ts'; import { bearer, createZugang, type Zugang } from './auth.ts';
import { createBus, type Bus, type OverlayEvent } from './bus.ts'; import { createBus, type Bus, type OverlayEvent } from './bus.ts';
import { startDemo } from './demo.ts'; import { startDemo } from './demo.ts';
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
import { createSoundboard, MAX_BYTES, nameAusDatei, type Soundboard } from './sounds.ts';
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts'; import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
const log = logger('overlay'); const log = logger('overlay');
@@ -46,6 +51,7 @@ const TYPES: Record<string, string> = {
'.png': 'image/png', '.png': 'image/png',
'.jpg': 'image/jpeg', '.jpg': 'image/jpeg',
'.woff2': 'font/woff2', '.woff2': 'font/woff2',
'.webmanifest': 'application/manifest+json; charset=utf-8',
}; };
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */ /** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
@@ -64,11 +70,18 @@ export const PAGES = [
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' }, title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
{ path: '/tts', file: 'tts.html', w: 600, h: 200, { path: '/tts', file: 'tts.html', w: 600, h: 200,
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' }, title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' },
{ path: '/sounds', file: 'sounds.html', w: 400, h: 120,
title: 'Soundboard', hint: 'Spielt die Sounds vom Pult. Unsichtbar, braucht Ton in OBS („Audio über OBS steuern“ an).' },
{ path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200,
title: 'Liegestütze', hint: 'Offene und in diesem Stream gemachte Liegestütze. Zählt bei Einlösung hoch, bedienen unten auf dieser Seite.' },
] as const; ] as const;
type LiegestuetzStand = { offen: number; gemacht: number };
export type OverlayServer = { export type OverlayServer = {
bus: Bus; bus: Bus;
zugang: Zugang; zugang: Zugang;
fern: Fernsteuerung;
close: () => void; close: () => void;
port: number; port: number;
}; };
@@ -79,6 +92,8 @@ export type OverlayOptions = {
/** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */ /** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */
publicUrl: string; publicUrl: string;
bus?: Bus; bus?: Bus;
/** Wo die Sounds liegen. Vorgabe: neben der Datenbank. */
soundsDir?: string;
/** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */ /** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */
setup?: ServerDeps; setup?: ServerDeps;
/** /**
@@ -96,6 +111,15 @@ export type OverlayOptions = {
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */ /** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
audio: (id: string) => Buffer | undefined; audio: (id: string) => Buffer | undefined;
}; };
/** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */
liegestuetze?: {
stand: () => LiegestuetzStand;
/** Negativ heisst gemacht: das Abgezogene zaehlt fuer diesen Stream. */
aendern: (n: number) => LiegestuetzStand;
/** Korrekturen. Zaehlen nicht als gemacht. */
setzen: (n: number) => LiegestuetzStand;
gemachtSetzen: (n: number) => LiegestuetzStand;
};
}; };
export function startOverlayServer(opts: OverlayOptions): OverlayServer { export function startOverlayServer(opts: OverlayOptions): OverlayServer {
@@ -103,13 +127,25 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
const zugang = createZugang(); const zugang = createZugang();
// Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll // Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll
// den ersten ersetzen, nicht danebenlaufen. // den ersten ersetzen, nicht danebenlaufen.
const vorfuehrung = { abbruch: null as AbortController | null }; const lz: Laufzeit = {
vorfuehrung: { abbruch: null },
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
// Der PC bekommt beim Verbinden die Adresse der Sound-Quelle und legt sie
// damit selbst in OBS an. Der Overlay-Schluessel darf nur lesen, und der
// PC haette ihn ohnehin - er steht in jeder Browser-Source in OBS.
fern: createFernsteuerung(bus, {
willkommen: () => ({
soundsUrl: `${opts.publicUrl || `http://127.0.0.1:${opts.port}`}/sounds?key=${zugang.overlayKey()}`,
}),
}),
quellen: new Map(),
};
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne // Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
// oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen. // oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen.
const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1'); const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1');
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => { void handle(req, res, bus, zugang, opts, lz).catch((err: Error) => {
log.error(`Anfrage fehlgeschlagen: ${err.message}`); log.error(`Anfrage fehlgeschlagen: ${err.message}`);
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Interner Fehler'); res.end('Interner Fehler');
@@ -130,23 +166,35 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : '')); ? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : ''));
}); });
return { bus, zugang, close: () => server.close(), port: opts.port }; return { bus, zugang, fern: lz.fern, close: () => server.close(), port: opts.port };
} }
type Vorfuehrung = { abbruch: AbortController | null }; type Vorfuehrung = { abbruch: AbortController | null };
/** Was der Server zwischen den Anfragen haelt. */
type Laufzeit = {
vorfuehrung: Vorfuehrung;
sounds: Soundboard;
fern: Fernsteuerung;
/** Verbundene Browser-Sources je Seite, z. B. sounds -> 1. */
quellen: Map<string, number>;
};
async function handle( async function handle(
req: http.IncomingMessage, res: http.ServerResponse, req: http.IncomingMessage, res: http.ServerResponse,
bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung bus: Bus, zugang: Zugang, opts: OverlayOptions, lz: Laufzeit
): Promise<void> { ): Promise<void> {
const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`); const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`);
const p = url.pathname; const p = url.pathname;
const { vorfuehrung, sounds, fern } = lz;
// ---- Anmeldung. Bewusst vor allem anderen. // ---- Anmeldung. Bewusst vor allem anderen.
if (p === '/login' && req.method === 'POST') { if (p === '/login' && req.method === 'POST') {
const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? ''); const form = await readForm(req);
if (!cookie) return redirect(res, '/?fehler=1'); const weiter = sichererPfad(form.get('weiter'));
res.writeHead(303, { location: '/', 'set-cookie': cookie }); const cookie = zugang.anmelden(form.get('passwort') ?? '');
if (!cookie) return redirect(res, `${weiter}?fehler=1`);
res.writeHead(303, { location: weiter, 'set-cookie': cookie });
res.end(); res.end();
return; return;
} }
@@ -164,18 +212,65 @@ async function handle(
// ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten, // ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten,
// also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen. // also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen.
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, '')); if (/\.(css|js|svg|png|jpg|woff2|webmanifest)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
// ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
// Adresse: wer ihn hat, schaltet Szenen um.
if (p.startsWith('/fern/')) {
if (!zugang.darfFern(req)) {
// Ein unbekannter Schluessel an der Leitung ist ein PC, der sich
// anmelden will. Er wartet auf "Freigeben" im Pult und fragt so lange
// nach. Alles andere unter /fern/ bleibt ohne gueltigen Schluessel zu.
const key = bearer(req.headers.authorization);
if (p === '/fern/befehle' && req.method === 'GET' && key && key.length >= 24 && key.length <= 128) {
fern.anfragen(key, String(req.headers['x-abdulive-rechner'] ?? ''), absender(req));
return send(res, 202, 'application/json; charset=utf-8',
JSON.stringify({ text: 'Wartet auf Freigabe im Pult.' }));
}
return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
}
if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
let roh: unknown;
try { roh = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8')); } catch {
return send(res, 400, 'text/plain; charset=utf-8', 'Kein JSON.');
}
if (p === '/fern/stand') fern.melden(roh); else fern.antwort(roh);
res.writeHead(204);
res.end();
return;
}
return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden');
}
// ---- Die Browser-Sources und ihr Datenstrom. // ---- Die Browser-Sources und ihr Datenstrom.
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p); // Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
// Uebersicht (POST, mit Anmeldung). Ohne die Einschraenkung bekamen die
// statt einer Antwort die Overlay-Seite - oder ohne Schluessel ein 403.
const page = req.method === 'GET' || req.method === 'HEAD'
? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1]; const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId) { const soundId = /^\/soundboard\/datei\/([a-f0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId || soundId) {
if (!zugang.darfOverlay(req, url)) { if (!zugang.darfOverlay(req, url)) {
return send(res, 403, 'text/plain; charset=utf-8', return send(res, 403, 'text/plain; charset=utf-8',
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' + 'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
'Sie steht fertig auf der Uebersichtsseite.'); 'Sie steht fertig auf der Uebersichtsseite.');
} }
if (p === '/events') return sse(req, res, bus); if (p === '/events') return sse(req, res, bus, url, lz.quellen);
if (soundId) {
const d = sounds.datei(soundId);
if (!d) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
res.writeHead(200, {
'content-type': d.typ, 'content-length': d.daten.length,
// Darf zwischengespeichert werden: eine ID gehoert immer zu derselben
// Datei, und beim zweiten Druck auf denselben Knopf soll es sofort knallen.
'cache-control': 'private, max-age=86400, immutable',
'x-content-type-options': 'nosniff',
});
res.end(d.daten);
return;
}
if (audioId) { if (audioId) {
const wav = opts.tts?.audio(audioId); const wav = opts.tts?.audio(audioId);
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.'); if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
@@ -190,9 +285,64 @@ async function handle(
// ---- Ab hier nur mit Anmeldung. // ---- Ab hier nur mit Anmeldung.
if (!zugang.istAngemeldet(req)) { if (!zugang.istAngemeldet(req)) {
return send(res, 401, 'text/html; charset=utf-8', // Fragen von Skripten (fetch) bekommen eine kurze Antwort statt der
loginPage(opts.channel, url.searchParams.has('fehler'))); // Anmeldeseite, damit das Pult "abgemeldet" sagen kann statt HTML zu parsen.
if (req.method !== 'GET' || p.startsWith('/soundboard/') || p.startsWith('/pult/')) {
return send(res, 401, 'application/json; charset=utf-8',
JSON.stringify({ text: 'Abgemeldet. Bitte neu anmelden.', abgemeldet: true }));
} }
return send(res, 401, 'text/html; charset=utf-8',
loginPage(opts.channel, url.searchParams.has('fehler'), p));
}
// ---- Das Pult
if (p === '/pult' && req.method === 'GET') return sendFile(res, 'pult.html');
if (p === '/pult/knoepfe' && req.method === 'GET') {
return send(res, 200, 'application/json; charset=utf-8', JSON.stringify({
knoepfe: TEST_KNOEPFE, demo: vorfuehrung.abbruch !== null,
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
}));
}
if (p === '/pult/fern') {
const liste = (code: number, text: string): void =>
send(res, code, 'application/json; charset=utf-8', JSON.stringify({
text, anfragen: fern.offeneAnfragen(), verbunden: fern.verbunden(),
}));
if (req.method === 'GET') return liste(200, '');
const form = await readForm(req);
const was = form.get('was');
if (was === 'freigeben') {
const key = fern.freigeben(form.get('id') ?? '');
if (!key) return liste(404, 'Die Anfrage ist abgelaufen. Der PC fragt gleich neu an.');
zugang.fernKeySetzen(key);
// Die alte Leitung (falls es eine gab) gehoert zu einem anderen Schluessel.
fern.trennen();
return liste(200, 'Freigegeben. Der Stream-PC verbindet sich in ein paar Sekunden.');
}
if (was === 'ablehnen') {
return liste(200, fern.ablehnen(form.get('id') ?? '') ? 'Abgelehnt.' : 'Schon weg.');
}
if (was === 'trennen') {
zugang.neuerFernKey();
fern.trennen();
return liste(200, 'Getrennt. Der PC muss neu freigegeben werden.');
}
return liste(400, 'Unbekannt.');
}
if (p === '/pult/obs' && req.method === 'POST') {
const form = await readForm(req);
const befehl = befehlAus(form);
if (!befehl) {
return send(res, 400, 'application/json; charset=utf-8',
JSON.stringify({ ok: false, text: 'Unbekannter Befehl.' }));
}
const a = await fern.senden(befehl);
if (a.ok) log.info(`Pult -> OBS: ${befehl.art}${'szene' in befehl ? ` "${befehl.szene}"` : ''}`);
return send(res, a.ok ? 200 : 502, 'application/json; charset=utf-8', JSON.stringify(a));
}
// ---- Soundboard
if (p.startsWith('/soundboard/')) return soundboardRoute(req, res, url, bus, lz);
// ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts // ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts
// davon landet in der Datenbank, auf Discord oder bei Twitch. // davon landet in der Datenbank, auf Discord oder bei Twitch.
@@ -276,14 +426,50 @@ async function handle(
JSON.stringify({ wartend: opts.tts.wartend() })); JSON.stringify({ wartend: opts.tts.wartend() }));
} }
// ---- Liegestuetze von Hand korrigieren
if (p === '/liegestuetze' && req.method === 'POST' && opts.liegestuetze) {
const form = await readForm(req);
const was = form.get('was') ?? '';
const n = zahlAus(form.get('wert'));
const antwort = (text: string, code = 200): void =>
send(res, code, 'application/json; charset=utf-8',
JSON.stringify({ text, ...opts.liegestuetze!.stand() }));
if (n === null) return antwort('Keine ganze Zahl.', 400);
if (was === 'aendern') {
const neu = opts.liegestuetze.aendern(n);
return antwort(n < 0
? `${-n} gemacht. Noch ${neu.offen} offen, ${neu.gemacht} in diesem Stream.`
: `+${n}, jetzt ${neu.offen} offen.`);
}
if (n < 0) return antwort('Weniger als 0 geht nicht.', 400);
if (was === 'setzen') {
return antwort(`Offen auf ${opts.liegestuetze.setzen(n).offen} gesetzt.`);
}
if (was === 'gemacht-setzen') {
return antwort(`Gemacht auf ${opts.liegestuetze.gemachtSetzen(n).gemacht} gesetzt.`);
}
return antwort('Unbekannt.', 400);
}
if (p === '/schluessel/neu' && req.method === 'POST') { if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey(); zugang.neuerOverlayKey();
// Der PC verbindet sich neu, bekommt die neue Adresse der Sound-Quelle
// und traegt sie in OBS ein. Die uebrigen Browser-Sources nicht - die hat
// jemand von Hand angelegt, und nur der weiss, wo sie liegen.
fern.trennen();
return redirect(res, '/'); return redirect(res, '/');
} }
if (p === '/fernschluessel/neu' && req.method === 'POST') {
zugang.neuerFernKey();
fern.trennen();
return redirect(res, '/#stream-pc');
}
if (p === '/' || p === '/index.html') { if (p === '/' || p === '/index.html') {
return send(res, 200, 'text/html; charset=utf-8', return send(res, 200, 'text/html; charset=utf-8',
indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null)); indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null,
{ key: zugang.fernKey(), verbunden: fern.verbunden() }));
} }
if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return; if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return;
@@ -293,7 +479,16 @@ async function handle(
// ---------------------------------------------------------------- SSE // ---------------------------------------------------------------- SSE
function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void { function sse(
req: http.IncomingMessage, res: http.ServerResponse, bus: Bus,
url: URL, quellen: Map<string, number>
): void {
// Welche Seite da zuhoert. Nur zum Zaehlen: das Pult will wissen, ob die
// Sound-Quelle in OBS ueberhaupt offen ist, bevor es "gespielt" meldet.
const roh = url.searchParams.get('quelle') ?? '';
const quelle = /^[a-z0-9-]{1,20}$/.test(roh) ? roh : '';
if (quelle) quellen.set(quelle, (quellen.get(quelle) ?? 0) + 1);
res.writeHead(200, { res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8', 'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform', 'cache-control': 'no-cache, no-transform',
@@ -323,7 +518,15 @@ function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): voi
HEARTBEAT_MS); HEARTBEAT_MS);
beat.unref(); beat.unref();
const end = (): void => { clearInterval(beat); unsubscribe(); }; // `close` und `error` kommen gern beide - gezaehlt wird nur einmal.
let zu = false;
const end = (): void => {
if (zu) return;
zu = true;
clearInterval(beat);
unsubscribe();
if (quelle) quellen.set(quelle, Math.max(0, (quellen.get(quelle) ?? 1) - 1));
};
req.on('close', end); req.on('close', end);
res.on('error', end); res.on('error', end);
} }
@@ -335,8 +538,129 @@ function zustand(bus: Bus) {
return { live: s.stream.live, chat: s.chat }; return { live: s.stream.live, chat: s.chat };
} }
// ---------------------------------------------------------------- Pult
/** Formular vom Pult in einen Befehl fuer OBS. Alles andere ist `null`. */
export function befehlAus(form: URLSearchParams): FernBefehl | null {
const was = form.get('was');
const szene = form.get('szene');
const quelle = form.get('quelle');
if (was === 'szene' && szene) return { art: 'szene', szene };
if (was === 'clip') return { art: 'clip' };
if (was === 'stumm' && quelle) return { art: 'stumm', quelle, stumm: form.get('stumm') === '1' };
return null;
}
async function soundboardRoute(
req: http.IncomingMessage, res: http.ServerResponse, url: URL, bus: Bus, lz: Laufzeit
): Promise<void> {
const p = url.pathname;
const { sounds } = lz;
const json = (code: number, body: Record<string, unknown>): void =>
send(res, code, 'application/json; charset=utf-8', JSON.stringify(body));
const mitListe = (code: number, text: string): void =>
json(code, { text, sounds: sounds.liste(), quellen: lz.quellen.get('sounds') ?? 0 });
const geaendert = (): void => bus.publish({ kind: 'sounds-geaendert' });
const zuGross = `Zu groß: höchstens ${MAX_BYTES / 1024 / 1024} MB.`;
if (p === '/soundboard/liste' && req.method === 'GET') return mitListe(200, '');
if (req.method !== 'POST') return json(405, { text: 'Nur POST.' });
if (p === '/soundboard/neu') {
// Die Datei kommt roh im Koerper, nicht als Formular: das erspart das
// Zerlegen von multipart, und das Pult schickt ohnehin genau eine Datei.
if (Number(req.headers['content-length'] ?? 0) > MAX_BYTES) return mitListe(413, zuGross);
let daten: Buffer;
try { daten = await readBody(req, MAX_BYTES); } catch { return mitListe(413, zuGross); }
try {
const s = sounds.hinzufuegen(nameAusDatei(url.searchParams.get('name') ?? ''), daten);
log.info(`Sound hochgeladen: ${s.name} (${(s.bytes / 1024).toFixed(0)} KB, ${s.typ})`);
geaendert();
return json(200, { text: `„${s.name}“ ist drin.`, sound: s, sounds: sounds.liste() });
} catch (err) {
return mitListe(400, (err as Error).message);
}
}
const form = await readForm(req);
const id = form.get('id') ?? '';
if (p === '/soundboard/spielen') {
const s = sounds.holen(id);
if (!s) return mitListe(404, 'Den Sound gibt es nicht mehr.');
bus.publish({ kind: 'sound', id: s.id, name: s.name, laut: s.laut });
const n = lz.quellen.get('sounds') ?? 0;
// Gespielt wird trotzdem: vielleicht ist die Quelle nur kurz weg. Aber
// wer auf einen Knopf drueckt und nichts hoert, soll wissen, warum.
return json(200, {
text: n ? s.name : `${s.name} – aber in OBS ist keine Sound-Quelle verbunden.`,
quellen: n,
});
}
if (p === '/soundboard/stopp') {
bus.publish({ kind: 'sound-stopp' });
return json(200, { text: 'Alle Sounds gestoppt.' });
}
if (p === '/soundboard/aendern') {
const was: Parameters<Soundboard['aendern']>[1] = {};
for (const k of ['name', 'emoji', 'farbe'] as const) {
const v = form.get(k);
if (v !== null) was[k] = v;
}
const laut = form.get('laut');
if (laut !== null) was.laut = Number(laut);
if (!sounds.aendern(id, was)) return mitListe(404, 'Den Sound gibt es nicht mehr.');
geaendert();
return mitListe(200, 'Gespeichert.');
}
if (p === '/soundboard/loeschen') {
const name = sounds.holen(id)?.name;
if (!sounds.entfernen(id)) return mitListe(404, 'Den Sound gibt es nicht mehr.');
log.info(`Sound geloescht: ${name}`);
geaendert();
return mitListe(200, `„${name}“ gelöscht.`);
}
if (p === '/soundboard/verschieben') {
if (sounds.verschieben(id, Number(form.get('um')))) geaendert();
return mitListe(200, '');
}
return json(404, { text: 'Nicht gefunden.' });
}
// ---------------------------------------------------------------- Werkzeug // ---------------------------------------------------------------- Werkzeug
/**
* Wohin es nach der Anmeldung geht. Nur eine feste Liste: ein freies Ziel
* machte /login zur Weiterleitung auf beliebige fremde Seiten.
*/
export function sichererPfad(roh: string | null | undefined): string {
return roh === '/pult' ? '/pult' : '/';
}
/**
* Woher eine Anfrage kommt. Hinter dem Proxy steht die echte Adresse im Kopf,
* die eigene Verbindung kommt immer vom Proxy. Nur zur Anzeige bei der
* Freigabe - nichts haengt daran, ob sie stimmt.
*/
function absender(req: http.IncomingMessage): string {
const kopf = req.headers['x-real-ip'] ?? String(req.headers['x-forwarded-for'] ?? '').split(',')[0];
return String(kopf || req.socket.remoteAddress || '').trim();
}
function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > max) { reject(new Error('Anfrage zu gross')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function readForm(req: http.IncomingMessage): Promise<URLSearchParams> { function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -351,6 +675,13 @@ function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
}); });
} }
/** Eingabe von der Seite in eine ganze Zahl. Alles andere ist `null`. */
export function zahlAus(roh: string | null | undefined): number | null {
if (roh === null || roh === undefined) return null;
const s = roh.trim();
return /^[+-]?\d{1,6}$/.test(s) ? Number(s) : null;
}
function redirect(res: http.ServerResponse, to: string): void { function redirect(res: http.ServerResponse, to: string): void {
res.writeHead(303, { location: to }); res.writeHead(303, { location: to });
res.end(); res.end();
@@ -427,16 +758,28 @@ const SEITEN_CSS = `
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; } gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
.wartend:first-child { border-top: 0; } .wartend:first-child { border-top: 0; }
.wartend .hint { max-width: 60ch; word-break: break-word; } .wartend .hint { max-width: 60ch; word-break: break-word; }
.lz-stand { font: 700 3rem/1 "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif;
font-variant-numeric: tabular-nums; margin: .4rem 0 1rem; }
.lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin: 0 2rem 0 .5rem; }
.lz-stand .gemacht { color: #4ade80; }
button.lz { margin-left: 0; min-width: 3.2rem; }
input.lz { width: 6.5rem; padding: .2rem .6rem; font-size: .85rem; }
button.tts { margin-left: .4rem; } button.tts { margin-left: .4rem; }
`; `;
export function loginPage(channel: string, fehler: boolean): string { export function loginPage(channel: string, fehler: boolean, weiter = '/'): string {
return `<!doctype html><html lang="de"><meta charset="utf-8"> return `<!doctype html><html lang="de"><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0b14">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<title>Anmelden</title><style>${SEITEN_CSS} <title>Anmelden</title><style>${SEITEN_CSS}
body { display: grid; place-items: center; height: 100vh; padding: 0; } body { display: grid; place-items: center; height: 100vh; height: 100dvh; padding: 0 1rem; }
form { text-align: left; } form { text-align: left; max-width: 100%; }
input[type=password] { max-width: 100%; }
</style> </style>
<form method="post" action="/login"> <form method="post" action="/login">
<input type="hidden" name="weiter" value="${sichererPfad(weiter)}">
<h1>abdulive <span>${escape(channel)}</span></h1> <h1>abdulive <span>${escape(channel)}</span></h1>
<p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p> <p class="lead">${fehler ? '<span class="warn">Passwort stimmt nicht.</span>' : 'Passwort eingeben.'}</p>
<input type="password" name="passwort" autofocus autocomplete="current-password" size="30"> <input type="password" name="passwort" autofocus autocomplete="current-password" size="30">
@@ -451,7 +794,8 @@ export function loginPage(channel: string, fehler: boolean): string {
* die Seitenliste, und das soll nicht an zwei Stellen stehen. * die Seitenliste, und das soll nicht an zwei Stellen stehen.
*/ */
export function indexPage( export function indexPage(
opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false,
fernInfo: { key: string; verbunden: boolean } = { key: '', verbunden: false }
): string { ): string {
// Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt - // Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt -
// im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben. // im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben.
@@ -466,6 +810,31 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span> <span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
</div> </div>
<p id="tts-meldung" class="meldung">&nbsp;</p>` : ''; <p id="tts-meldung" class="meldung">&nbsp;</p>` : '';
const lzFeld = opts.liegestuetze ? `
<h2>Liegestütze</h2>
<p class="lead">Jede Einlösung zählt hoch. Mit −1, −5, −10 herunterzählen, wenn
sie gemacht sind: das zählt für diesen Stream als gemacht. „Setzen“ ist nur
für Korrekturen und zählt nicht. Die Zuschauer sehen jede Änderung.</p>
<div class="lz-stand"><span id="lz-offen">${opts.liegestuetze.stand().offen}</span><small>offen</small><span id="lz-gemacht" class="gemacht">${opts.liegestuetze.stand().gemacht}</span><small>gemacht in diesem Stream</small></div>
<div class="gruppe">
<button class="lz" data-um="-10">−10</button>
<button class="lz" data-um="-5">−5</button>
<button class="lz" data-um="-1">−1</button>
<button class="lz" data-um="1">+1</button>
<button class="lz" data-um="5">+5</button>
<button class="lz" data-um="10">+10</button>
</div>
<form class="gruppe" id="lz-form">
<input class="lz" id="lz-wert" type="number" min="0" step="1" inputmode="numeric" placeholder="Anzahl">
<button type="submit" class="lz">offen setzen</button>
<button type="button" class="lz" id="lz-null">offen auf 0</button>
</form>
<form class="gruppe" id="lz-gemacht-form">
<input class="lz" id="lz-gemacht-wert" type="number" min="0" step="1" inputmode="numeric" placeholder="Anzahl">
<button type="submit" class="lz">gemacht setzen</button>
<span class="hint">Beginnt bei jedem neuen Stream bei 0.</span>
</form>
<p id="lz-meldung" class="meldung">&nbsp;</p>` : '';
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`; const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
const rows = PAGES.map((p) => { const rows = PAGES.map((p) => {
const id = `u${p.path.slice(1)}`; const id = `u${p.path.slice(1)}`;
@@ -490,9 +859,27 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
).join('')} ).join('')}
</div>`).join(''); </div>`).join('');
const fernBefehl = `abdulive fern:verbinden ${base} ${fernInfo.key}`;
return `<!doctype html><html lang="de"><meta charset="utf-8"> return `<!doctype html><html lang="de"><meta charset="utf-8">
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}</style> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0d0b14">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<title>Overlay - ${escape(opts.channel)}</title><style>${SEITEN_CSS}
.pultbox { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.2rem;
border: 1px solid #3a3352; border-radius: .6rem; padding: .9rem 1.1rem;
margin: 0 0 2rem; max-width: 74rem; background: #120e1d; }
.pultbox a.gross { margin: 0; padding: .45rem 1rem; border-color: #a970ff; color: #fff;
background: #6441a5; font-weight: 600; }
</style>
<h1>Overlay <span>${escape(opts.channel)}</span></h1> <h1>Overlay <span>${escape(opts.channel)}</span></h1>
<div class="pultbox">
<a class="knopf gross" href="/pult">Pult öffnen</a>
<span class="hint">Soundboard, Szenen, Liegestütze und Vorlesen fürs Handy.
Dort im Browser-Menü „Zum Startbildschirm hinzufügen“ bzw. „App installieren“ -
dann liegt es wie eine App auf dem Handy.</span>
</div>
<p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und <p class="lead">In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und
Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst
verpasst das Overlay alles, was in einer anderen Szene passiert.</p> verpasst das Overlay alles, was in einer anderen Szene passiert.</p>
@@ -524,6 +911,26 @@ ${testfeld}
${ttsFeld} ${ttsFeld}
${lzFeld}
<h2 id="stream-pc">Stream-PC verbinden</h2>
<p class="lead">Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten
kann, wählt sich der Stream-PC hier ein. Das macht er von selbst: beim ersten
Start meldet er sich, und im Pult unter <a href="/pult#szenen">Szenen</a> erscheint
„Freigeben“. Danach legt er in OBS auch die Sound-Quelle selbst an.</p>
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
<details><summary class="hint">Von Hand verbinden, falls die Freigabe nicht klappt</summary>
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
<button data-copy="fernbefehl">kopieren</button></p>
<p class="hint">Auf dem Stream-PC in PowerShell, danach <code>abdulive ingest</code> neu starten.</p>
</details>
<form method="post" action="/fernschluessel/neu">
<button type="submit" style="margin-left:0">Schlüssel neu würfeln</button>
<span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen,
nie zeigen. Nach dem Würfeln den Befehl oben auf dem Stream-PC erneut ausführen.</span>
</form>
<h2>Schlüssel</h2> <h2>Schlüssel</h2>
<form method="post" action="/schluessel/neu"> <form method="post" action="/schluessel/neu">
<button type="submit">Schlüssel neu würfeln</button> <button type="submit">Schlüssel neu würfeln</button>
@@ -565,11 +972,57 @@ ${ttsFeld}
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel') ? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
: 'verbunden - kein Stream'; : 'verbunden - kein Stream';
setzeLive(ev.stream.live); setzeLive(ev.stream.live);
if (ev.liegestuetze) zeigeLiegestuetze(ev.liegestuetze);
} else if (ev.kind === 'stream') { } else if (ev.kind === 'stream') {
setzeLive(ev.live); setzeLive(ev.live);
} else if (ev.kind === 'liegestuetze') {
zeigeLiegestuetze(ev);
} }
}; };
// Der Stand kommt über den Ereignisstrom, damit eine Einlösung auch hier
// sofort sichtbar ist und zwei offene Fenster nicht auseinanderlaufen.
function zeigeLiegestuetze(st) {
for (const [id, n] of [['lz-offen', st.offen], ['lz-gemacht', st.gemacht]]) {
const el = document.getElementById(id);
if (el && typeof n === 'number') el.textContent = n.toLocaleString('de-DE');
}
}
const lzMeldung = document.getElementById('lz-meldung');
async function lzRuf(was, wert) {
const res = await fetch('/liegestuetze', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ was, wert: String(wert) }),
});
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
zeigeLiegestuetze(body);
lzMeldung.textContent = body.text || '';
lzMeldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
setTimeout(() => { lzMeldung.innerHTML = '&nbsp;'; lzMeldung.className = 'meldung'; }, 4000);
}
for (const b of document.querySelectorAll('button.lz[data-um]')) {
b.addEventListener('click', () => lzRuf('aendern', b.dataset.um));
}
const lzForm = document.getElementById('lz-form');
if (lzForm) {
lzForm.addEventListener('submit', (e) => {
e.preventDefault();
const feld = document.getElementById('lz-wert');
if (feld.value === '') return;
lzRuf('setzen', feld.value);
feld.value = '';
});
document.getElementById('lz-null').addEventListener('click', () => lzRuf('setzen', 0));
document.getElementById('lz-gemacht-form').addEventListener('submit', (e) => {
e.preventDefault();
const feld = document.getElementById('lz-gemacht-wert');
if (feld.value === '') return;
lzRuf('gemacht-setzen', feld.value);
feld.value = '';
});
}
let sagAb; let sagAb;
async function loese(art) { async function loese(art) {
const res = await fetch('/test', { const res = await fetch('/test', {
+89
View File
@@ -0,0 +1,89 @@
// Der Sound-Speicher nimmt Dateien vom Handy an und liefert sie unter der
// eigenen Adresse wieder aus. Hier wird vor allem geprueft, dass er nur Ton
// annimmt - und dass die Liste einen Neustart uebersteht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createSoundboard, erkenneTyp, nameAusDatei, MAX_BYTES } from './sounds.ts';
const ordner = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-sounds-'));
const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.alloc(64, 1)]);
const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(32)]);
const M4A = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypM4A '), Buffer.alloc(32)]);
test('Tontypen werden an den ersten Bytes erkannt', () => {
assert.equal(erkenneTyp(MP3), 'audio/mpeg');
assert.equal(erkenneTyp(Buffer.from([0xff, 0xfb, 0x90, 0x64, 0, 0, 0, 0, 0, 0, 0, 0])), 'audio/mpeg');
assert.equal(erkenneTyp(WAV), 'audio/wav');
assert.equal(erkenneTyp(Buffer.concat([Buffer.from('OggS'), Buffer.alloc(20)])), 'audio/ogg');
assert.equal(erkenneTyp(M4A), 'audio/mp4');
});
test('HTML ist kein Ton, auch wenn der Browser es behauptet', () => {
// Sonst waere das eine Ablage fuer beliebige Seiten unter der eigenen Adresse.
assert.equal(erkenneTyp(Buffer.from('<!doctype html><script>alert(1)</script>')), null);
assert.equal(erkenneTyp(Buffer.from('kurz')), null);
const sb = createSoundboard(ordner());
assert.throws(() => sb.hinzufuegen('x', Buffer.from('<html><body>hallo welt</body></html>')), /Kein Ton/);
assert.equal(sb.liste().length, 0);
});
test('zu gross wird abgelehnt, bevor etwas auf der Platte landet', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const riesig = Buffer.concat([MP3, Buffer.alloc(MAX_BYTES)]);
assert.throws(() => sb.hinzufuegen('lied', riesig), /Zu gross/);
assert.deepEqual(fs.readdirSync(dir), []);
});
test('hochladen, abholen, nach Neustart noch da', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const s = sb.hinzufuegen('Tusch', MP3);
assert.equal(s.typ, 'audio/mpeg');
assert.ok(s.datei.endsWith('.mp3'));
assert.deepEqual(sb.datei(s.id)?.daten, MP3);
const nachNeustart = createSoundboard(dir);
assert.equal(nachNeustart.liste()[0]?.name, 'Tusch');
assert.deepEqual(nachNeustart.datei(s.id)?.daten, MP3);
});
test('aendern nimmt nur, was erlaubt ist', () => {
const sb = createSoundboard(ordner());
const s = sb.hinzufuegen('Tusch', MP3);
const neu = sb.aendern(s.id, {
name: ' Ba\ndum tss ', emoji: '🥁', farbe: 'url(javascript:x)', laut: 3,
})!;
assert.equal(neu.name, 'Ba dum tss');
assert.equal(neu.emoji, '🥁');
assert.equal(neu.farbe, s.farbe, 'freie Farbwerte waeren CSS-Einschleusung');
assert.equal(neu.laut, 1);
assert.equal(sb.aendern(s.id, { name: ' ' })?.name, 'Ba dum tss', 'leerer Name behaelt den alten');
});
test('verschieben und loeschen', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const a = sb.hinzufuegen('A', MP3);
const b = sb.hinzufuegen('B', WAV);
sb.hinzufuegen('C', M4A);
assert.equal(sb.verschieben(b.id, -1), true);
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'A', 'C']);
assert.equal(sb.verschieben(b.id, -1), false, 'vor dem ersten ist nichts');
assert.equal(sb.entfernen(a.id), true);
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'C']);
assert.equal(fs.existsSync(path.join(dir, a.datei)), false, 'die Datei geht mit');
assert.equal(sb.entfernen(a.id), false);
});
test('Name aus dem Dateinamen', () => {
assert.equal(nameAusDatei('ba_dum_tss.mp3'), 'ba dum tss');
assert.equal(nameAusDatei('.mp3'), 'Sound');
assert.equal(nameAusDatei('Aufnahme 12.m4a'), 'Aufnahme 12');
});
+187
View File
@@ -0,0 +1,187 @@
// Die Sounds fuer das Pult.
//
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
// /sounds. Dazwischen liegt nur dieser Ordner:
//
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
// <db>/sounds/<id>.<endung> die Datei selbst
//
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
//
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export type Sound = {
id: string;
name: string;
emoji: string;
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
farbe: string;
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
laut: number;
datei: string;
typ: string;
bytes: number;
seit: string;
};
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
export const MAX_BYTES = 8 * 1024 * 1024;
export const MAX_SOUNDS = 120;
const ENDUNG: Record<string, string> = {
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
'audio/mp4': '.m4a',
};
/**
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
* Browser sicher abspielt.
*/
export function erkenneTyp(b: Buffer): string | null {
if (b.length < 12) return null;
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
return null;
}
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
export function nameAusDatei(datei: string): string {
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
return saeubern(ohne) || 'Sound';
}
function saeubern(s: string, max = 40): string {
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
.slice(0, max).join('');
}
function emojiOk(s: string): string {
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
}
export type Soundboard = {
liste: () => Sound[];
holen: (id: string) => Sound | undefined;
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
hinzufuegen: (name: string, daten: Buffer) => Sound;
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
entfernen: (id: string) => boolean;
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
verschieben: (id: string, um: number) => boolean;
};
export function createSoundboard(ordner: string): Soundboard {
const index = path.join(ordner, 'sounds.json');
const sounds: Sound[] = lesen();
function lesen(): Sound[] {
try {
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
return Array.isArray(roh.sounds) ? roh.sounds : [];
} catch {
return [];
}
}
function schreiben(): void {
fs.mkdirSync(ordner, { recursive: true });
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
// bleibt die alte Liste ganz statt einer halben neuen.
const tmp = `${index}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
fs.renameSync(tmp, index);
}
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
return {
liste: () => sounds.map((s) => ({ ...s })),
holen,
datei(id) {
const s = holen(id);
if (!s) return undefined;
try {
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
} catch {
return undefined;
}
},
hinzufuegen(name, daten) {
if (daten.length > MAX_BYTES) {
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
}
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
const typ = erkenneTyp(daten);
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
const id = crypto.randomBytes(6).toString('hex');
const datei = `${id}${ENDUNG[typ]}`;
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(path.join(ordner, datei), daten);
const s: Sound = {
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
};
sounds.push(s);
schreiben();
return { ...s };
},
aendern(id, was) {
const s = holen(id);
if (!s) return undefined;
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
if (was.laut !== undefined && Number.isFinite(was.laut)) {
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
}
schreiben();
return { ...s };
},
entfernen(id) {
const i = sounds.findIndex((s) => s.id === id);
if (i === -1) return false;
const [weg] = sounds.splice(i, 1);
schreiben();
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
return true;
},
verschieben(id, um) {
const i = sounds.findIndex((s) => s.id === id);
const ziel = i + Math.sign(um);
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
schreiben();
return true;
},
};
}
+18 -2
View File
@@ -96,6 +96,23 @@ export const TEST_KNOEPFE: TestKnopf[] = [
{ art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' }, { art: 'live-aus', label: 'Stand zurücksetzen', gruppe: 'Stand' },
]; ];
// Twitchs globale Abzeichen. Die Adressen sind fest, so zeigt die Vorfuehrung
// echte Bilder, ohne Helix fragen zu muessen.
const ABZEICHEN = 'https://static-cdn.jtvnw.net/badges/v1/';
const MOD = { url: `${ABZEICHEN}3267646d-33f0-4b17-b3df-f923a41db1d0/2`, title: 'Moderator' };
const VIP = { url: `${ABZEICHEN}b817aba4-fad8-49e2-b88a-7cc744dfa6ec/2`, title: 'VIP' };
const ABO = { url: `${ABZEICHEN}5d9f2208-5dd8-11e7-8513-2ff4adfae661/2`, title: 'Subscriber' };
function zufallsAbzeichen(): Pick<ChatEvent, 'mod' | 'sub' | 'badges'> {
const mod = Math.random() < 0.15;
const vip = !mod && Math.random() < 0.1;
const sub = Math.random() < 0.3;
return {
mod, sub,
badges: [...(mod ? [MOD] : []), ...(vip ? [VIP] : []), ...(sub ? [ABO] : [])],
};
}
function chat(over: Partial<ChatEvent> = {}): ChatEvent { function chat(over: Partial<ChatEvent> = {}): ChatEvent {
const name = zufall(NAMEN); const name = zufall(NAMEN);
return { return {
@@ -109,8 +126,7 @@ function chat(over: Partial<ChatEvent> = {}): ChatEvent {
emotes: [], emotes: [],
gifs: [], gifs: [],
first: false, first: false,
mod: Math.random() < 0.15, ...zufallsAbzeichen(),
sub: Math.random() < 0.3,
broadcaster: false, broadcaster: false,
bits: 0, bits: 0,
...over, ...over,
+73 -2
View File
@@ -17,6 +17,7 @@ und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes).
| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. | | `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. |
| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. | | `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. |
| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. | | `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. |
| `/sounds` | 400×120 | Spielt die Sounds vom Pult. Unsichtbar, **mit Ton**. |
Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`). Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`).
@@ -81,8 +82,78 @@ auf einen echten Raid wartet.
bleibt das Overlay bei Follows stumm; Abos, Raids und Bits kommen an. 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 - **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene
Emotes werden angezeigt. Emotes werden angezeigt.
- **Töne.** Es liegen keine Audiodateien im Repo, und ein Overlay, das - **Töne bei Einblendungen.** Es liegen keine Audiodateien im Repo, und ein
ungefragt Krach macht, ist schlimmer als eines, das still ist. Overlay, das ungefragt Krach macht, ist schlimmer als eines, das still ist.
Sounds gibt es nur auf Knopfdruck, über das Pult.
## Das Pult (`/pult`)
Keine Browser-Source, sondern eine App fürs Handy: wie ein Stream Deck, nur
ohne Hardware. Mit Passwort, wie die Übersicht. Im Browser des Handys
„Zum Startbildschirm hinzufügen“ (iPhone) bzw. „App installieren“ (Android),
dann startet es ohne Adressleiste. `manifest.webmanifest` und `sw.js` machen
das möglich; der Service Worker hält nur die Hülle vor und beantwortet nie
einen Knopfdruck aus dem Speicher.
| Reiter | Was |
|---|---|
| Sounds | Tasten fürs Soundboard. „+ Sound“ lädt MP3, WAV, OGG, WebM oder M4A vom Handy hoch (bis 8 MB). „Bearbeiten“: Name, Emoji, Farbe, Lautstärke, Reihenfolge, Löschen, am Handy anhören. „Stopp“ bricht alle Sounds ab. |
| Szenen | OBS-Szenen umschalten, Clip aus dem Wiedergabepuffer speichern, Toneingänge stumm schalten. Braucht den Stream-PC (siehe unten). |
| Stream | Liegestütze zählen, Vorlesen freigeben, „Sofort still“. |
| Chat | Der Twitch-Chat, **nur lesend**: Abzeichen, Emotes, GIFs, Erstchatter hervorgehoben, Abos, Raids und Belohnungen als eigene Zeile, von Mods Gelöschtes durchgestrichen. |
| Test | Dieselben Testknöpfe wie auf der Übersicht. |
**Am Tablet** (ab 760 px Breite und 500 px Höhe) steht der Chat fest rechts
und links der gewählte Reiter, der Chat-Reiter entfällt. Ohne gemerkten
Reiter geht das Pult dort auf Szenen auf. Quer gehaltene Handys sind zu
niedrig dafür und behalten die Reiter.
Der Chat hängt am selben Ereignisstrom wie die Browser-Sources und schreibt
nie. Er braucht kein Bot-Konto und keine weiteren Rechte bei Twitch. Nach
dem Neuverbinden kommen die letzten 30 Nachrichten aus dem Schnappschuss,
im Pult stehen höchstens 250. Wer hochscrollt, bleibt dort; „Neue
Nachrichten“ springt zurück ans Ende.
Die Sonne oben rechts hält den Bildschirm an (Wake Lock), solange das Pult
offen ist.
**Sounds** liegen auf dem Hub neben der Datenbank (`<db>/sounds/`), nicht im
Repo und nicht unter `/opt/abdulive` - sie überleben jedes Ausrollen. Der
Dateityp wird an den ersten Bytes erkannt; was kein Ton ist, wird abgelehnt.
Gespielt wird in OBS über die Browser-Source `/sounds`, „Audio über OBS
steuern“ an. Ist sie nicht verbunden, sagt das Pult es in Gelb. Höchstens vier
Sounds gleichzeitig (`?max=`), `?laut=` dämpft alle.
**Szenen** gehen über den Stream-PC, weil der Hub OBS nicht erreicht. Der PC
wählt sich mit eigenem Schlüssel beim Hub ein (`apps/ingest/src/fern.ts` und
`apps/overlay-server/src/fern.ts`). In OBS muss nur obs-websocket an sein -
dasselbe, was `!clip` braucht.
Einzurichten ist nichts. Beim ersten Start würfelt `ingest` einen Schlüssel
und meldet sich bei `fern.url` (Vorgabe `https://abdu.dimension47.de`,
`FERN_URL=` schaltet ab). Im Pult unter Szenen steht dann „Stream-PC … möchte
sich verbinden“ mit **Freigeben**. Ein Tipp, nach ein paar Sekunden ist er da.
„Stream-PC trennen“ unten im Reiter würfelt den Schlüssel neu; danach muss er
neu freigegeben werden. `abdulive fern:verbinden <adresse> <schlüssel>` bleibt
als Weg von Hand (Befehl auf der Übersicht).
Jeder Knopf wartet auf die Antwort von OBS und sagt ehrlich, wenn es nicht ging.
**Die Sound-Quelle legt der PC selbst an.** Nach dem Verbinden schickt der Hub
die Adresse von `/sounds` mit Schlüssel, und `ingest` legt in OBS an:
- die Szene `abdulive Ton` mit der Browser-Source `abdulive Sounds` darin
(„Audio über OBS steuern“ an, beim Unsichtbarwerden nicht abschalten);
- in **jeder** Szene ein Verweis auf `abdulive Ton`. OBS gibt Ton nur aus
Quellen der gerade gezeigten Szene aus; so gibt es die Quelle einmal, sie
läuft beim Szenenwechsel durch und ist überall zu hören.
Neue Szenen bekommen den Verweis sofort, ein neuer Overlay-Schlüssel wird in
die Quelle nachgetragen. Die Ton-Szene taucht im Pult nicht als Szene auf, die
Quelle aber unter Ton - damit lässt sich das Soundboard stumm schalten. Wer den
Verweis aus einer Szene löscht, bekommt ihn beim nächsten Start zurück.
Die App-Symbole zeichnet `node scripts/make-icons.ts`.
## Vorlesen (`/tts`) ## Vorlesen (`/tts`)
+14 -13
View File
@@ -53,18 +53,15 @@
background: rgba(0, 0, 0, .35); background: rgba(0, 0, 0, .35);
} }
/* Abzeichen statt Bilder: Twitchs Abzeichen-URLs haengen am Kanal und muessten /* Twitchs eigene Abzeichen. Die Bilder loest der Hub ueber Helix auf und
* ueber Helix aufgeloest werden. Ein farbiger Punkt sagt dasselbe. */ * schickt sie mit jeder Zeile mit (#twitch/badges.ts). */
.abzeichen { .abzeichen {
display: inline-block; height: 1.05em;
width: .5em; height: .5em; width: auto;
border-radius: 50%; margin-right: .3em;
margin-right: .4em; vertical-align: -.17em;
vertical-align: .08em;
} }
.abzeichen.mod { background: var(--gruen); } .abzeichen:last-of-type { margin-right: .45em; }
.abzeichen.abo { background: var(--lila); }
.abzeichen.host { background: var(--rot); }
/* Wer zum ersten Mal schreibt, wird hervorgehoben - das ist der Moment, in /* 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 * dem eine Begruessung am meisten bringt. Ohne den Balken an der linken Kante
@@ -152,9 +149,13 @@
el.dataset.user = ev.userId; el.dataset.user = ev.userId;
var abzeichen = ''; var abzeichen = '';
if (ev.broadcaster) abzeichen += '<span class="abzeichen host"></span>'; var bilder = ev.badges || [];
else if (ev.mod) abzeichen += '<span class="abzeichen mod"></span>'; for (var i = 0; i < bilder.length; i++) {
if (ev.sub) abzeichen += '<span class="abzeichen abo"></span>'; // 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 = el.innerHTML =
abzeichen + abzeichen +
+5 -1
View File
@@ -27,7 +27,11 @@
function verbinde() { function verbinde() {
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } } if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : ''); // `quelle` sagt dem Server, welche Seite zuhoert. Er zaehlt damit nur -
// das Pult will wissen, ob die Sound-Quelle in OBS ueberhaupt offen ist.
var quelleName = location.pathname.replace(/^\/+|\.html$/g, '') || 'index';
var url = '/events?quelle=' + encodeURIComponent(quelleName) +
(params.get('key') ? '&key=' + encodeURIComponent(params.get('key')) : '');
quelle = new EventSource(url); quelle = new EventSource(url);
letzteNachricht = Date.now(); letzteNachricht = Date.now();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+233
View File
@@ -0,0 +1,233 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Liegestütze</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Liegestütze aus Kanalpunkten: groß die offenen, darunter die in diesem
* Stream schon gemachten.
*
* Eine Einlösung zählt hoch und zeigt kurz, wer es war. Wenn Abdu auf der
* Übersichtsseite herunterzählt, blinkt die Zahl grün und "gemacht" wächst -
* ohne Namen, damit das nicht wie eine Einlösung aussieht.
*
* In OBS: Browser-Quelle 420x200. Einstellungen in der Adresse:
* ?verstecken=1 ausblenden, solange nichts offen und nichts gemacht ist
* ?gemacht=0 die Zeile "gemacht" weglassen
* ?titel=... andere Beschriftung als "Liegestütze" */
#karte {
position: absolute;
left: 12px; bottom: 12px;
display: flex; align-items: center; gap: 18px;
padding: 16px 26px 16px 18px;
overflow: hidden;
transition: opacity .5s, transform .5s, box-shadow .4s, border-color .4s;
}
#karte.weg { opacity: 0; transform: translateY(14px); }
.symbol {
width: 58px; height: 58px; flex: none;
display: grid; place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, var(--lila), var(--pink));
box-shadow: 0 6px 18px rgba(169, 112, 255, .45);
}
.symbol svg { width: 36px; height: 36px; }
.text { min-width: 0; }
#titel {
font: 600 13px/1 var(--font-zahl);
letter-spacing: .16em; text-transform: uppercase;
color: var(--schrift-leise);
}
#zahl {
display: block;
font: 800 54px/1 var(--font);
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
margin-top: 6px;
}
#zahl.erledigt { font-size: 30px; color: var(--gruen); padding: 8px 0 6px; }
#gemacht {
display: flex; align-items: center; gap: 6px;
margin-top: 7px;
font: 600 16px/1 var(--font-zahl);
font-variant-numeric: tabular-nums;
color: var(--schrift-leise);
}
#gemacht b { color: var(--gruen); font-weight: 800; }
#gemacht svg { width: 15px; height: 15px; color: var(--gruen); }
#gemacht.aus { display: none; }
#gemacht.mehr b { animation: gemacht-mehr .9s ease-out; display: inline-block; }
@keyframes gemacht-mehr {
0% { transform: scale(1.35); }
100% { transform: none; }
}
/* Einlösung: Karte glüht gold, Zahl springt. */
#karte.mehr { border-color: var(--gold); box-shadow: var(--schatten), 0 0 28px rgba(255, 197, 61, .55); }
#zahl.springen { animation: springen .6s cubic-bezier(.3, 1.6, .5, 1); }
@keyframes springen {
0% { transform: scale(1); }
40% { transform: scale(1.22); color: var(--gold); }
100% { transform: scale(1); }
}
/* Korrektur nach unten: kurz grün, das heißt "gemacht". */
#zahl.weniger { animation: weniger .9s ease-out; }
@keyframes weniger {
0% { color: var(--gruen); transform: translateY(-4px); }
100% { color: inherit; transform: none; }
}
/* "+5 von Name", steigt über der Karte auf. */
#plus {
position: absolute;
left: 12px; bottom: 140px;
max-width: 396px;
padding: 8px 14px;
font: 700 20px/1.1 var(--font);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
opacity: 0;
}
#plus b { color: var(--gold); margin-right: 6px; }
#plus.zeigen { animation: aufsteigen 3.6s ease-out both; }
@keyframes aufsteigen {
0% { opacity: 0; transform: translateY(16px); }
12% { opacity: 1; transform: none; }
80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(-10px); }
}
</style>
</head>
<body>
<div id="plus" class="glas"></div>
<div id="karte" class="glas">
<div class="symbol" aria-hidden="true">
<!-- Figur im Liegestütz: Kopf, Rücken, Arme, Boden. -->
<svg viewBox="0 0 48 48" fill="none" stroke="#fff" stroke-width="3.4"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="17" r="4" fill="#fff" stroke="none"/>
<path d="M15 21 L41 29"/>
<path d="M17 22 L15 33"/>
<path d="M41 29 L42 33"/>
<path d="M6 37 H44" stroke-width="2.4" opacity=".7"/>
</svg>
</div>
<div class="text">
<div id="titel" class="kontur">Liegestütze</div>
<span id="zahl" class="kontur">0</span>
<div id="gemacht" class="kontur">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5"/>
</svg>
<span><b>0</b> gemacht</span>
</div>
</div>
</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var karte = document.getElementById('karte');
var zahl = document.getElementById('zahl');
var plus = document.getElementById('plus');
var gemacht = document.getElementById('gemacht');
var gemachtZahl = gemacht.querySelector('b');
if (!Overlay.param('gemacht', true)) gemacht.classList.add('aus');
var verstecken = Overlay.param('verstecken', false);
document.getElementById('titel').textContent = Overlay.param('titel', 'Liegestütze');
var angezeigt = null; // was gerade im Bild steht
var gemachtBisher = null;
var lauf = null; // laufendes Hochzählen
var glimmen = null;
function schreibe(n) {
if (n === 0 && !verstecken) {
zahl.textContent = 'erledigt';
zahl.classList.add('erledigt');
} else {
zahl.textContent = Overlay.zahl(n);
zahl.classList.remove('erledigt');
}
}
function sichtbarkeit() {
karte.classList.toggle('weg', verstecken && angezeigt === 0 && !gemachtBisher);
}
function zeigeGemacht(g) {
if (typeof g !== 'number') return;
var alt = gemachtBisher;
gemachtBisher = g;
gemachtZahl.textContent = Overlay.zahl(g);
if (alt !== null && g > alt) nochmal(gemacht, 'mehr');
}
function nochmal(el, klasse) {
el.classList.remove(klasse);
void el.offsetWidth;
el.classList.add(klasse);
}
/** Zählt sichtbar von der alten zur neuen Zahl hoch. Bei großen Sprüngen
* nicht jede einzelne Zahl, sonst dauert ein Setzen auf 500 ewig. */
function hochzaehlen(von, bis) {
clearInterval(lauf);
var schritte = Math.min(bis - von, 20);
var i = 0;
lauf = setInterval(function () {
i++;
schreibe(i >= schritte ? bis : Math.round(von + (bis - von) * i / schritte));
if (i >= schritte) clearInterval(lauf);
}, 45);
}
function stand(n, einloesung) {
if (typeof n !== 'number') return;
var alt = angezeigt;
angezeigt = n;
// Erster Stand nach dem Verbinden: einfach hinschreiben, ohne Effekt.
if (alt === null || alt === n) { clearInterval(lauf); schreibe(n); return; }
if (n > alt) {
hochzaehlen(alt, n);
nochmal(zahl, 'springen');
karte.classList.add('mehr');
clearTimeout(glimmen);
glimmen = setTimeout(function () { karte.classList.remove('mehr'); }, 2500);
} else {
clearInterval(lauf);
schreibe(n);
nochmal(zahl, 'weniger');
}
if (einloesung && einloesung.name) {
plus.innerHTML = '<b>+' + Overlay.esc(einloesung.dazu) + '</b>' +
'von ' + Overlay.esc(einloesung.name);
nochmal(plus, 'zeigen');
}
}
Overlay.on('state', function (ev) {
if (!ev.liegestuetze) return;
stand(ev.liegestuetze.offen);
zeigeGemacht(ev.liegestuetze.gemacht);
sichtbarkeit();
});
Overlay.on('liegestuetze', function (ev) {
stand(ev.offen, ev.dazu ? { dazu: ev.dazu, name: ev.name } : null);
zeigeGemacht(ev.gemacht);
sichtbarkeit();
});
})();
</script>
</body>
</html>
@@ -0,0 +1,22 @@
{
"name": "abdulive Pult",
"short_name": "Pult",
"description": "Soundboard, Szenen, Chat, Liegestütze und Vorlesen für den Stream von abdulive_.",
"lang": "de",
"id": "/pult",
"start_url": "/pult",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0d0b14",
"theme_color": "#0d0b14",
"icons": [
{ "src": "/icons/pult-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/pult-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/pult-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Szenen", "url": "/pult#szenen", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] },
{ "name": "Chat", "url": "/pult#chat", "icons": [{ "src": "/icons/pult-192.png", "sizes": "192x192" }] }
]
}
+493
View File
@@ -0,0 +1,493 @@
/* Das Pult. Fuers Handy gebaut, nicht fuer OBS: anders als die
* Browser-Sources hat es einen deckenden Hintergrund und will angefasst
* werden. Farben wie in theme.css, damit Pult und Overlay zusammengehoeren.
*
* Grundregel fuer alles, was man im Stream mit einem Daumen trifft: eine
* Taste ist mindestens 48 px hoch, und was passiert ist, sieht man an der
* Taste selbst - nicht nur in einer Meldung am Rand. */
:root {
color-scheme: dark;
--grund: #0d0b14;
--flaeche: #171327;
--flaeche-2: #1f1a33;
--linie: #2c2642;
--linie-stark: #3a3352;
--schrift: #ece9f6;
--leise: #9b94b3;
--lila: #a970ff;
--lila-tief: #6441a5;
--tuerkis: #4fd1c5;
--pink: #ff4fa3;
--gold: #ffc53d;
--gruen: #4ade80;
--rot: #ef4444;
--blau: #60a5fa;
--grau: #8b84a3;
--rund: 16px;
--kopf-h: 56px;
--reiter-h: 64px;
--oben: env(safe-area-inset-top, 0px);
--unten: env(safe-area-inset-bottom, 0px);
--font: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
--font-zahl: "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { background: var(--grund); }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
background: var(--grund);
color: var(--schrift);
font: 16px/1.45 var(--font);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
a { color: var(--lila); }
code {
font: .85em "Cascadia Mono", ui-monospace, Consolas, monospace;
background: var(--flaeche-2); padding: .1em .35em; border-radius: 6px;
}
svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round; flex: none; }
[hidden] { display: none !important; }
/* ------------------------------------------------------------ Kopf */
.kopf {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
height: calc(var(--kopf-h) + var(--oben));
padding: var(--oben) 16px 0;
background: rgba(13, 11, 20, .88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--linie);
}
.kanal { display: flex; align-items: center; gap: 12px; min-width: 0; }
.marke { font: 600 17px/1 var(--font-zahl); letter-spacing: -.01em; }
.marke b { color: var(--lila); font-weight: 700; }
.live {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
font: 600 13px/1.2 var(--font-zahl); font-variant-numeric: tabular-nums;
background: rgba(239, 68, 68, .16); color: #ffb3b3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--rot);
animation: puls 1.6s ease-in-out infinite; flex: none; }
.live.aus { background: var(--flaeche-2); color: var(--leise); }
.live.aus i { background: var(--grau); animation: none; }
.kopf-rechts { display: flex; align-items: center; gap: 8px; }
.rund {
display: grid; place-items: center; width: 40px; height: 40px;
border-radius: 50%; border: 1px solid var(--linie); background: transparent;
color: var(--leise); cursor: pointer;
}
.rund[aria-pressed="true"] { color: var(--gold); border-color: rgba(255, 197, 61, .45);
background: rgba(255, 197, 61, .1); }
.netz { display: grid; place-items: center; width: 24px; height: 40px; }
.netz i { width: 10px; height: 10px; border-radius: 50%; background: var(--rot);
box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); }
.netz.an i { background: var(--gruen); box-shadow: 0 0 0 4px rgba(74, 222, 128, .14); }
.banner {
position: sticky; top: calc(var(--kopf-h) + var(--oben)); z-index: 19;
padding: 10px 16px; font-size: 14px;
background: #3b1d1d; color: #ffd0d0; border-bottom: 1px solid #5c2a2a;
}
@keyframes puls { 50% { opacity: .35; } }
/* ------------------------------------------------------------ Inhalt */
main {
padding: 16px 16px calc(var(--reiter-h) + var(--unten) + 88px);
max-width: 980px; margin: 0 auto;
}
.tab { display: none; }
.tab.an { display: block; animation: rein .18s ease-out; }
@keyframes rein { from { opacity: 0; transform: translateY(4px); } }
.leiste { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.luecke { flex: 1; }
.abschnitt {
display: flex; align-items: center; gap: 8px;
margin: 26px 0 10px; font: 600 13px/1 var(--font); letter-spacing: .08em;
text-transform: uppercase; color: var(--leise);
}
.abschnitt:first-child { margin-top: 4px; }
.klein { font-size: 13px; color: var(--leise); margin: 10px 0; }
.klein.mitte { text-align: center; }
.karte {
background: var(--flaeche); border: 1px solid var(--linie);
border-radius: var(--rund); padding: 16px; margin-bottom: 16px;
}
.karte .abschnitt { margin-top: 0; }
.hinweis {
border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; font-size: 14px;
background: var(--flaeche); border: 1px solid var(--linie);
}
.hinweis.warn { background: rgba(255, 197, 61, .08); border-color: rgba(255, 197, 61, .4);
color: #ffe2a0; }
.hinweis.warn a { color: var(--gold); }
/* ------------------------------------------------------------ Knoepfe */
.knopf {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 44px; padding: 0 16px;
font: 600 15px/1 var(--font); color: var(--schrift);
background: var(--flaeche-2); border: 1px solid var(--linie-stark);
border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none;
text-decoration: none; touch-action: manipulation;
transition: transform .08s, background .15s, border-color .15s;
}
.knopf:active { transform: scale(.96); }
.knopf:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.knopf.primaer { background: var(--lila-tief); border-color: var(--lila); color: #fff; }
.knopf.warnung { color: #ffb3b3; border-color: rgba(239, 68, 68, .5); background: rgba(239, 68, 68, .1); }
.knopf.gross { min-height: 52px; padding: 0 22px; font-size: 16px; }
.knopf.breit { width: 100%; }
.knopf[aria-pressed="true"] { background: var(--schrift); color: var(--grund); border-color: var(--schrift); }
.knopf.wartet { opacity: .7; pointer-events: none; }
.knopf:focus-visible, .kachel:focus-visible, .szene:focus-visible, .rund:focus-visible,
.reiter button:focus-visible { outline: 2px solid var(--lila); outline-offset: 2px; }
/* ------------------------------------------------------------ Soundraster */
.raster {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 10px;
}
@media (min-width: 640px) { .raster { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 12px; } }
.kachel {
--c: var(--lila);
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
aspect-ratio: 1; padding: 10px 8px; min-width: 0;
border-radius: 18px; cursor: pointer; overflow: hidden;
color: #fff; font: 600 14px/1.2 var(--font); text-align: center;
background:
radial-gradient(120% 90% at 30% 0%, color-mix(in oklab, var(--c) 42%, transparent), transparent 70%),
color-mix(in oklab, var(--c) 14%, var(--flaeche));
border: 1px solid color-mix(in oklab, var(--c) 45%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
touch-action: manipulation; user-select: none; -webkit-user-select: none;
transition: transform .08s ease-out;
}
.kachel:active { transform: scale(.93); }
.kachel .emoji { font-size: 34px; line-height: 1; }
.kachel .name {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; overflow-wrap: anywhere; max-width: 100%;
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
.kachel.ohne-emoji .name { font-size: 16px; -webkit-line-clamp: 3; }
.kachel::after {
content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
background: color-mix(in oklab, var(--c) 70%, #fff); opacity: 0;
}
.kachel.knall::after { animation: knall .6s ease-out; }
@keyframes knall { 0% { opacity: .55; } 100% { opacity: 0; } }
.kachel.knall { animation: wippen .35s ease-out; }
@keyframes wippen { 40% { transform: scale(1.04); } }
.raster.bearbeiten .kachel { animation: zittern .5s ease-in-out infinite alternate; }
.raster.bearbeiten .kachel:nth-child(2n) { animation-delay: -.25s; }
@keyframes zittern { from { transform: rotate(-.8deg); } to { transform: rotate(.8deg); } }
.raster.bearbeiten .kachel::before {
content: "✎"; position: absolute; top: 6px; right: 8px; font-size: 14px; opacity: .85;
}
.kachel[data-farbe="lila"], .farbe[data-farbe="lila"] { --c: var(--lila); }
.kachel[data-farbe="tuerkis"], .farbe[data-farbe="tuerkis"] { --c: var(--tuerkis); }
.kachel[data-farbe="pink"], .farbe[data-farbe="pink"] { --c: var(--pink); }
.kachel[data-farbe="gold"], .farbe[data-farbe="gold"] { --c: var(--gold); }
.kachel[data-farbe="gruen"], .farbe[data-farbe="gruen"] { --c: var(--gruen); }
.kachel[data-farbe="rot"], .farbe[data-farbe="rot"] { --c: var(--rot); }
.kachel[data-farbe="blau"], .farbe[data-farbe="blau"] { --c: var(--blau); }
.kachel[data-farbe="grau"], .farbe[data-farbe="grau"] { --c: var(--grau); }
.kachel.laedt { opacity: .55; pointer-events: none; }
.kachel.laedt .name::after { content: " …"; }
.leer { text-align: center; padding: 32px 12px; color: var(--leise); }
.leer h2 { color: var(--schrift); font-size: 20px; margin: 18px 0 6px; }
.leer p { max-width: 34ch; margin: 0 auto 20px; }
.leer-bild { display: inline-grid; grid-template-columns: repeat(3, 34px); gap: 6px; }
.leer-bild span { aspect-ratio: 1; border-radius: 9px; border: 1.5px dashed var(--linie-stark); }
.leer-bild span:nth-child(2) { border-color: var(--lila); background: rgba(169, 112, 255, .12); }
.leer-bild span:nth-child(6) { border-color: var(--tuerkis); background: rgba(79, 209, 197, .1); }
/* ------------------------------------------------------------ Szenen */
.szenen { display: grid; gap: 8px; }
@media (min-width: 640px) { .szenen { grid-template-columns: repeat(2, 1fr); } }
.szene {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
min-height: 64px; padding: 0 18px; width: 100%;
font: 600 17px/1.2 var(--font); color: var(--schrift); text-align: left;
background: var(--flaeche); border: 1px solid var(--linie-stark); border-radius: var(--rund);
cursor: pointer; touch-action: manipulation; transition: transform .08s, background .15s;
}
.szene:active { transform: scale(.98); }
.szene .szene-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.szene .marke-an {
font: 700 11px/1 var(--font); letter-spacing: .1em; text-transform: uppercase;
padding: 5px 8px; border-radius: 6px; background: rgba(255, 255, 255, .16); display: none;
}
.szene.aktiv { background: var(--lila-tief); border-color: var(--lila); }
.szene.aktiv .marke-an { display: inline-block; }
.szene.wartet { opacity: .6; }
.knopf.clip {
width: 100%; min-height: 64px; font-size: 17px; border-radius: var(--rund);
background: rgba(255, 79, 163, .14); border-color: rgba(255, 79, 163, .55); color: #ffd1e8;
}
.knopf.clip svg { width: 24px; height: 24px; }
.ton { display: grid; gap: 8px; }
.ton-zeile {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 56px; padding: 0 14px 0 16px;
font: 500 15px/1.2 var(--font); color: var(--schrift); text-align: left;
background: var(--flaeche); border: 1px solid var(--linie); border-radius: 14px; cursor: pointer;
}
.ton-zeile .ton-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ton-zeile .ton-stand {
font: 600 12px/1 var(--font); letter-spacing: .06em; text-transform: uppercase;
padding: 7px 10px; border-radius: 8px; background: rgba(74, 222, 128, .14); color: var(--gruen);
}
.ton-zeile.stumm { border-color: rgba(239, 68, 68, .45); }
.ton-zeile.stumm .ton-stand { background: rgba(239, 68, 68, .16); color: #ffb3b3; }
.ton-zeile.stumm svg { color: #ffb3b3; }
.anfragen { display: grid; gap: 12px; margin-bottom: 4px; }
.anfrage {
border-radius: var(--rund); padding: 16px; margin-bottom: 12px;
background: rgba(169, 112, 255, .1); border: 1px solid var(--lila);
}
.anfrage h2 { font-size: 18px; margin: 0 0 4px; }
.anfrage p { margin: 0 0 14px; color: var(--leise); font-size: 14px; }
.anfrage .reihe { display: flex; gap: 8px; }
.anfrage .reihe .primaer { flex: 1; min-height: 52px; font-size: 16px; }
.knopf.leise { margin-top: 28px; background: transparent; color: var(--leise); border-color: var(--linie); font-weight: 500; }
.leer-karte { text-align: center; padding: 28px 18px; }
.leer-karte h2 { font-size: 18px; margin: 0 0 8px; }
.leer-karte p { color: var(--leise); margin: 0; font-size: 14px; }
/* ------------------------------------------------------------ Stream */
.lz-zahlen { display: flex; gap: 28px; margin: 4px 0 16px; }
.lz-zahlen div { display: flex; flex-direction: column; }
.lz-zahlen b { font: 700 52px/1 var(--font-zahl); font-variant-numeric: tabular-nums; }
.lz-zahlen span { color: var(--leise); font-size: 13px; margin-top: 4px; }
.lz-zahlen .gemacht b { color: var(--gruen); }
.lz-knoepfe { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.lz-knoepfe .knopf { min-height: 56px; font-size: 18px; font-variant-numeric: tabular-nums; }
.zaehler {
display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
border-radius: 999px; background: var(--pink); color: #fff; font-size: 12px; letter-spacing: 0;
}
.wartend { display: grid; gap: 10px; margin-bottom: 12px; }
.wartend .leer-zeile { color: var(--leise); font-size: 14px; }
.nachricht { background: var(--flaeche-2); border-radius: 12px; padding: 12px; }
.nachricht .wer { font-weight: 600; color: var(--tuerkis); font-size: 14px; }
.nachricht .was { margin: 4px 0 10px; overflow-wrap: anywhere; }
.nachricht .reihe { display: flex; gap: 8px; }
.nachricht .reihe .knopf { flex: 1; }
.knopf.panik {
width: 100%; min-height: 64px; margin-top: 8px; font-size: 18px; border-radius: var(--rund);
background: var(--rot); border-color: var(--rot); color: #fff;
}
.knopf.panik svg { width: 24px; height: 24px; }
/* ------------------------------------------------------------ Test */
.gruppe { margin-bottom: 4px; }
.gruppe .knoepfe { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.gruppe .knopf { min-height: 48px; font-weight: 500; font-size: 14px; padding: 0 10px; }
/* ------------------------------------------------------------ Chat */
/* Ein fester Kasten zwischen Kopf und Reitern, damit nur die Liste
* scrollt und nicht die Seite: wer im Chat hochscrollt, soll den Kopf mit
* LIVE-Anzeige und Verbindungspunkt weiter sehen. */
.tab.chat.an { display: flex; }
.chat {
position: fixed; z-index: 10; left: 0; right: 0;
top: calc(var(--kopf-h) + var(--oben));
bottom: calc(var(--reiter-h) + var(--unten));
flex-direction: column;
background: var(--grund);
}
.chat-kopf {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
padding: 14px 16px 10px; border-bottom: 1px solid var(--linie);
}
.chat-kopf .abschnitt, .chat-kopf .klein { margin: 0; }
/* Wenige Nachrichten stehen unten wie in jedem Chat, nicht oben unter dem
* Kopf. margin-top: auto statt justify-content: flex-end - mit letzterem
* laesst sich in manchen Browsern nicht mehr nach oben scrollen. */
.chat-liste {
display: flex; flex-direction: column;
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding: 8px 10px 14px; font-size: 15px; line-height: 1.42;
}
.chat-leer { position: absolute; top: 50%; left: 0; right: 0; text-align: center;
color: var(--leise); font-size: 14px; margin: 0; }
.cz { flex: none; padding: 5px 8px; border-radius: 8px; overflow-wrap: anywhere; }
.cz:first-child { margin-top: auto; }
.cz .zeit { color: var(--leise); font-size: 12px; font-variant-numeric: tabular-nums; margin-right: 6px; }
.cz .abz { height: 18px; width: auto; vertical-align: -3px; margin-right: 3px; }
.cz .wer { font-weight: 700; }
.cz .emote { height: 28px; width: auto; vertical-align: -8px; }
.cz .gif { display: block; max-width: min(100%, 220px); max-height: 140px; border-radius: 6px; margin: 4px 0 2px; }
.cz.erstmals { background: rgba(74, 222, 128, .1); box-shadow: inset 3px 0 0 var(--gruen); }
.cz.erstmals::before {
content: "Zum ersten Mal hier"; display: block; margin-bottom: 1px;
font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--gruen);
}
.cz.bits { background: rgba(255, 197, 61, .1); }
.cz.weg { opacity: .45; }
.cz.weg .text { text-decoration: line-through; }
.cz.system {
margin: 6px 0; font-size: 13px; color: var(--schrift);
background: rgba(169, 112, 255, .1); border: 1px solid rgba(169, 112, 255, .35);
}
.chat-runter {
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
padding: 9px 16px; border-radius: 999px; cursor: pointer;
font: 600 14px/1 var(--font); color: #fff;
background: var(--lila-tief); border: 1px solid var(--lila);
box-shadow: 0 8px 24px rgba(0, 0, 0, .45);
}
/* Ab Tablet-Groesse: Chat fest rechts, links der gewaehlte Reiter. So
* stehen Szenen und Chat auf einem Bildschirm. Die Hoehe haelt quer
* gehaltene Handys heraus, dort bliebe fuer beides zu wenig. Muss in
* pult.js (`geteilt`) gleich lauten. */
@media (min-width: 760px) and (min-height: 500px) {
:root { --chat-b: clamp(300px, 36vw, 440px); }
main { max-width: none; margin: 0; padding-right: calc(var(--chat-b) + 16px); }
.tab.chat {
display: flex; left: auto; width: var(--chat-b); bottom: 0;
padding-bottom: var(--unten); border-left: 1px solid var(--linie); animation: none;
}
.chat-runter { bottom: calc(14px + var(--unten)); }
.reiter { right: var(--chat-b); }
#nav-chat { display: none; }
.meldung { left: calc((100% - var(--chat-b)) / 2); }
}
/* ------------------------------------------------------------ Reiter */
.reiter {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--reiter-h) + var(--unten));
padding-bottom: var(--unten);
background: rgba(18, 14, 29, .94);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--linie);
}
.reiter button {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
background: none; border: 0; color: var(--leise); cursor: pointer;
font: 500 12px/1 var(--font); touch-action: manipulation;
}
.reiter button svg { width: 24px; height: 24px; }
.reiter button.an { color: var(--schrift); }
.reiter button.an svg { color: var(--lila); }
.reiter .punkt { position: absolute; top: 10px; left: calc(50% + 10px); width: 9px; height: 9px;
border-radius: 50%; background: var(--pink); border: 2px solid var(--grund); }
/* ------------------------------------------------------------ Meldung */
.meldung {
position: fixed; left: 50%; z-index: 40;
bottom: calc(var(--reiter-h) + var(--unten) + 14px);
transform: translate(-50%, 12px);
max-width: min(92vw, 460px); padding: 12px 16px; border-radius: 12px;
background: #241f35; border: 1px solid var(--linie-stark); color: var(--schrift);
font-size: 15px; box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
}
.meldung.an { opacity: 1; transform: translate(-50%, 0); }
.meldung.ok { border-color: rgba(74, 222, 128, .5); }
.meldung.fehler { border-color: rgba(255, 197, 61, .6); color: #ffe2a0; }
/* ------------------------------------------------------------ Blatt */
.blatt {
width: 100%; max-width: 560px; margin: auto auto 0; padding: 0;
border: 1px solid var(--linie-stark); border-bottom: 0; border-radius: 22px 22px 0 0;
background: var(--flaeche); color: var(--schrift);
}
@media (min-width: 640px) { .blatt { margin: auto; border-radius: 22px; border-bottom: 1px solid var(--linie-stark); } }
.blatt::backdrop { background: rgba(5, 4, 9, .65); backdrop-filter: blur(2px); }
.blatt[open] { animation: hoch .22s ease-out; }
@keyframes hoch { from { transform: translateY(40px); opacity: 0; } }
.blatt form { padding: 10px 18px calc(18px + var(--unten)); display: grid; gap: 16px; }
.griff { width: 40px; height: 4px; border-radius: 4px; background: var(--linie-stark); margin: 0 auto; }
.blatt-kopf { display: flex; gap: 10px; }
.blatt input:not([type=range]) {
font: inherit; color: var(--schrift); background: var(--flaeche-2);
border: 1px solid var(--linie-stark); border-radius: 12px; min-height: 52px; padding: 0 14px;
}
.blatt input:focus { outline: 2px solid var(--lila); outline-offset: 0; }
.emoji-feld { width: 64px; text-align: center; font-size: 26px !important; padding: 0 !important; }
.name-feld { flex: 1; min-width: 0; font-size: 17px !important; }
.schnell { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.schnell button {
flex: none; width: 42px; height: 42px; border-radius: 10px; font-size: 22px;
background: var(--flaeche-2); border: 1px solid var(--linie); cursor: pointer;
}
.feld { display: grid; gap: 10px; }
.feld-name { font-size: 13px; color: var(--leise); display: flex; justify-content: space-between; }
.feld-name b { color: var(--schrift); font-variant-numeric: tabular-nums; }
.farben { display: flex; gap: 8px; flex-wrap: wrap; }
.farbe {
--c: var(--lila);
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
background: var(--c); border: 3px solid var(--flaeche); box-shadow: 0 0 0 1px var(--linie-stark);
}
.farbe[aria-checked="true"] { box-shadow: 0 0 0 2px var(--schrift); }
input[type=range] { width: 100%; accent-color: var(--lila); height: 32px; }
.blatt-reihe { display: flex; gap: 8px; align-items: center; }
.blatt-reihe #b-hoeren { flex: 1; }
/* ------------------------------------------------------------ Abgemeldet */
.abgemeldet {
position: fixed; inset: 0; z-index: 50; display: grid; place-items: center;
background: rgba(13, 11, 20, .96); text-align: center; padding: 24px;
}
.abgemeldet p { color: var(--leise); max-width: 32ch; margin: 8px auto 22px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+216
View File
@@ -0,0 +1,216 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0b14">
<meta name="color-scheme" content="dark">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Pult">
<title>Pult · abdulive</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/pult-192.png" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/pult.css">
</head>
<body>
<header class="kopf">
<div class="kanal">
<span class="marke">abdu<b>live</b></span>
<span id="live" class="live aus"><i></i><span id="live-text">offline</span></span>
</div>
<div class="kopf-rechts">
<button id="wach" class="rund" type="button" aria-pressed="false" title="Bildschirm anlassen">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/><circle cx="12" cy="12" r="4"/></svg>
</button>
<span id="netz" class="netz" title="Verbindung zum Hub"><i></i></span>
</div>
</header>
<div id="banner" class="banner" hidden></div>
<main>
<!-- ======================================================== Sounds -->
<section id="tab-sounds" class="tab an" aria-labelledby="nav-sounds">
<div class="leiste">
<button id="stopp" class="knopf warnung" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
Stopp
</button>
<span class="luecke"></span>
<button id="bearbeiten" class="knopf" type="button" aria-pressed="false">Bearbeiten</button>
<label class="knopf primaer" for="datei">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
Sound
</label>
<input id="datei" type="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.webm" multiple hidden>
</div>
<div id="quelle-fehlt" class="hinweis warn" hidden>
In OBS ist keine Sound-Quelle verbunden – die Zuschauer hören nichts. Browser-Source
<code>/sounds</code> anlegen, die Adresse steht auf der <a href="/">Übersicht</a>.
</div>
<div id="raster" class="raster" aria-live="polite"></div>
<div id="sounds-leer" class="leer" hidden>
<div class="leer-bild" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<h2>Noch keine Sounds</h2>
<p>MP3, WAV, OGG oder M4A vom Handy hochladen. Jeder Sound wird eine Taste;
ein Druck spielt ihn im Stream.</p>
<label class="knopf primaer gross" for="datei">Ersten Sound hinzufügen</label>
</div>
</section>
<!-- ======================================================== Szenen -->
<section id="tab-szenen" class="tab" aria-labelledby="nav-szenen">
<div id="anfragen" class="anfragen"></div>
<div id="obs-fehlt" class="karte leer-karte" hidden>
<h2 id="obs-fehlt-titel">Stream-PC nicht verbunden</h2>
<p id="obs-fehlt-text"></p>
</div>
<div id="obs-da" hidden>
<h3 class="abschnitt">Szenen</h3>
<div id="szenen" class="szenen"></div>
<h3 class="abschnitt">Clip</h3>
<button id="clip" class="knopf clip" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h3l2-3h6l2 3h3v12H4z"/><circle cx="12" cy="13" r="3.5"/></svg>
<span>Clip speichern</span>
</button>
<p id="clip-hinweis" class="klein"></p>
<h3 class="abschnitt">Ton</h3>
<div id="ton" class="ton"></div>
<button id="trennen" class="knopf breit leise" type="button">Stream-PC trennen</button>
</div>
</section>
<!-- ======================================================== Stream -->
<section id="tab-stream" class="tab" aria-labelledby="nav-stream">
<div id="lz-karte" class="karte" hidden>
<h3 class="abschnitt">Liegestütze</h3>
<div class="lz-zahlen">
<div><b id="lz-offen">0</b><span>offen</span></div>
<div class="gemacht"><b id="lz-gemacht">0</b><span>gemacht</span></div>
</div>
<div class="lz-knoepfe">
<button class="knopf" data-um="-10" type="button">−10</button>
<button class="knopf" data-um="-5" type="button">−5</button>
<button class="knopf" data-um="-1" type="button">−1</button>
<button class="knopf" data-um="1" type="button">+1</button>
<button class="knopf" data-um="5" type="button">+5</button>
<button class="knopf" data-um="10" type="button">+10</button>
</div>
<p class="klein">Minus zählt als gemacht. Korrekturen ohne Zählung auf der <a href="/">Übersicht</a>.</p>
</div>
<div id="tts-karte" class="karte" hidden>
<h3 class="abschnitt">Vorlesen <span id="tts-anzahl" class="zaehler" hidden></span></h3>
<div id="wartend" class="wartend"></div>
<button id="alle-ablehnen" class="knopf" type="button">Alle ablehnen</button>
</div>
<button id="still" class="knopf panik" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>
Sofort still
</button>
<p class="klein mitte">Bricht Vorlesen und alle Sounds ab und leert die Warteschlange.</p>
</section>
<!-- ======================================================== Test -->
<section id="tab-test" class="tab" aria-labelledby="nav-test">
<div id="test-live" class="hinweis warn" hidden>Der Stream läuft. Was du hier auslöst, sehen die Zuschauer.</div>
<p class="klein">Erfundene Ereignisse, nur für die Browser-Sources. Nichts davon landet
in der Datenbank, auf Discord oder bei Twitch.</p>
<div id="test-gruppen"></div>
<h3 class="abschnitt">Dauerbetrieb</h3>
<button id="demo" class="knopf breit" type="button" data-art="demo-an">Vorführung starten</button>
<p class="klein"><a href="/">Übersicht und OBS-Adressen</a> · <a href="/setup">Einrichtung</a> · <a href="/logout">Abmelden</a></p>
</section>
<!-- ======================================================== Chat
Am Handy ein Reiter, ab Tablet-Breite steht er rechts daneben. -->
<section id="tab-chat" class="tab chat" aria-labelledby="nav-chat">
<div class="chat-kopf">
<h3 class="abschnitt">Chat</h3>
<span class="klein">nur lesen</span>
</div>
<div id="chat-liste" class="chat-liste" role="log" aria-live="off"></div>
<p id="chat-leer" class="chat-leer">Noch keine Nachrichten.</p>
<button id="chat-runter" class="chat-runter" type="button" hidden>Neue Nachrichten ↓</button>
</section>
</main>
<div id="meldung" class="meldung" role="status" aria-live="polite"></div>
<nav class="reiter" aria-label="Bereiche">
<button id="nav-sounds" data-tab="sounds" class="an" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="2"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2"/></svg>
Sounds
</button>
<button id="nav-szenen" data-tab="szenen" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="13" rx="2"/><path d="M8 21h8"/></svg>
Szenen
<i id="nav-fern" class="punkt" hidden></i>
</button>
<button id="nav-stream" data-tab="stream" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
Stream
<i id="nav-tts" class="punkt" hidden></i>
</button>
<button id="nav-chat" data-tab="chat" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/></svg>
Chat
<i id="nav-chat-punkt" class="punkt" hidden></i>
</button>
<button id="nav-test" data-tab="test" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/></svg>
Test
</button>
</nav>
<dialog id="blatt" class="blatt">
<form method="dialog" id="blatt-form">
<div class="griff" aria-hidden="true"></div>
<div class="blatt-kopf">
<input id="b-emoji" class="emoji-feld" maxlength="10" placeholder="🔊" aria-label="Emoji" autocomplete="off">
<input id="b-name" class="name-feld" maxlength="40" placeholder="Name" aria-label="Name" autocomplete="off">
</div>
<div id="b-schnell" class="schnell" aria-label="Emoji-Vorschläge"></div>
<div class="feld">
<span class="feld-name">Farbe</span>
<div id="b-farben" class="farben" role="radiogroup" aria-label="Farbe"></div>
</div>
<label class="feld">
<span class="feld-name">Lautstärke <b id="b-laut-zahl">80 %</b></span>
<input id="b-laut" type="range" min="0" max="100" step="5" value="80">
</label>
<div class="blatt-reihe">
<button type="button" id="b-hoeren" class="knopf">Am Handy anhören</button>
<button type="button" id="b-vor" class="knopf" aria-label="Nach vorn">◀</button>
<button type="button" id="b-zurueck" class="knopf" aria-label="Nach hinten">▶</button>
</div>
<div class="blatt-reihe">
<button type="button" id="b-loeschen" class="knopf warnung">Löschen</button>
<span class="luecke"></span>
<button type="button" id="b-abbrechen" class="knopf">Abbrechen</button>
<button type="submit" id="b-speichern" class="knopf primaer">Speichern</button>
</div>
</form>
</dialog>
<div id="abgemeldet" class="abgemeldet" hidden>
<div>
<h2>Abgemeldet</h2>
<p>Die Anmeldung ist abgelaufen oder das Passwort wurde geändert.</p>
<a class="knopf primaer gross" href="/pult">Neu anmelden</a>
</div>
</div>
<script src="/pult.js"></script>
</body>
</html>
+876
View File
@@ -0,0 +1,876 @@
/* Das Pult: Soundboard, Szenen, Chat, Liegestuetze, Vorlesen - am Handy,
* ab Tablet-Groesse mit dem Chat daneben.
*
* Zwei Wege zum Hub:
* - der Ereignisstrom /events (wie bei den Browser-Sources), damit zwei
* offene Pulte und die Uebersichtsseite nicht auseinanderlaufen;
* - einfache POSTs fuer jeden Druck. Jede Antwort sagt, ob es geklappt
* hat, und genau das steht danach an der Taste.
*
* Fremder Text (Soundnamen, Szenen, Chat) kommt nur ueber textContent ins
* DOM, nie ueber innerHTML. */
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
/** Gleiche Grenze wie die Media Query in pult.css. */
var geteilt = window.matchMedia('(min-width: 760px) and (min-height: 500px)');
var FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'];
var SCHNELL = ['🔊', '😂', '💀', '👏', '🎺', '🥁', '💥', '🚨', '🤡', '😱', '🔥', '❤️', '👀', '🐐', '🫡', '🎉'];
var zustand = {
sounds: [],
obs: { pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false },
live: false,
bearbeiten: false,
ttsAn: false,
};
// ------------------------------------------------------------ Hilfen
function el(tag, klasse, text) {
var e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined) e.textContent = text;
return e;
}
function tippen() {
if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) { /* egal */ } }
}
var meldungTimer;
function melden(text, art) {
var m = $('meldung');
if (!text) return;
m.textContent = text;
m.className = 'meldung an' + (art ? ' ' + art : '');
clearTimeout(meldungTimer);
meldungTimer = setTimeout(function () { m.className = 'meldung'; }, art === 'fehler' ? 4500 : 2200);
}
/** POST mit Formular, Antwort als JSON. Wirft nie; Fehler stehen in `ok`. */
function rufen(pfad, felder, roh) {
var opts = { method: 'POST', credentials: 'same-origin' };
if (roh) {
opts.body = roh;
} else {
opts.headers = { 'content-type': 'application/x-www-form-urlencoded' };
opts.body = new URLSearchParams(felder || {});
}
return fetch(pfad, opts).then(function (res) {
return res.json().catch(function () { return { text: 'Fehler ' + res.status }; })
.then(function (body) {
if (res.status === 401) abgemeldet();
body.ok = res.ok && body.ok !== false;
return body;
});
}, function () {
return { ok: false, text: 'Keine Verbindung zum Hub.' };
});
}
function holen(pfad) {
return fetch(pfad, { credentials: 'same-origin' }).then(function (res) {
if (res.status === 401) { abgemeldet(); throw new Error('abgemeldet'); }
if (!res.ok) throw new Error('Fehler ' + res.status);
return res.json();
});
}
function abgemeldet() { $('abgemeldet').hidden = false; }
/** Taste waehrend eines Rufs sperren, damit ein Doppeltipp nicht doppelt schaltet. */
function mitWarten(knopf, versprechen) {
knopf.classList.add('wartet');
return versprechen.then(function (r) { knopf.classList.remove('wartet'); return r; });
}
// ------------------------------------------------------------ Reiter
function zeigeReiter(name) {
// Ab Tablet-Breite steht der Chat ohnehin daneben, links bleibt dann
// Platz fuer das, womit man ihn zusammen braucht.
if (name === 'chat' && geteilt.matches) name = 'szenen';
var reiter = document.querySelectorAll('.reiter button');
for (var i = 0; i < reiter.length; i++) {
reiter[i].classList.toggle('an', reiter[i].dataset.tab === name);
}
var tabs = document.querySelectorAll('.tab');
for (var j = 0; j < tabs.length; j++) tabs[j].classList.toggle('an', tabs[j].id === 'tab-' + name);
try { localStorage.setItem('pult-reiter', name); } catch (e) { /* privat */ }
if (name === 'stream') ladeWartend();
if (name === 'chat') chatGesehen();
window.scrollTo(0, 0);
}
document.querySelector('.reiter').addEventListener('click', function (e) {
var b = e.target.closest('button[data-tab]');
if (b) { tippen(); zeigeReiter(b.dataset.tab); }
});
// ------------------------------------------------------------ Kopf
var laufzeitTimer;
function zeigeStream(s) {
zustand.live = !!s.live;
var live = $('live');
live.classList.toggle('aus', !s.live);
clearInterval(laufzeitTimer);
var schreiben = function () {
if (!s.live) { $('live-text').textContent = 'offline'; return; }
var teile = ['LIVE'];
if (s.startedAt) {
var min = Math.max(0, Math.floor((Date.now() - Date.parse(s.startedAt)) / 60000));
teile.push(Math.floor(min / 60) + ':' + String(min % 60).padStart(2, '0'));
}
teile.push((s.viewers || 0).toLocaleString('de-DE') + ' 👁');
$('live-text').textContent = teile.join(' · ');
};
schreiben();
if (s.live) laufzeitTimer = setInterval(schreiben, 30000);
$('test-live').hidden = !s.live;
var gesperrt = document.querySelectorAll('#test-gruppen [data-offline]');
for (var i = 0; i < gesperrt.length; i++) gesperrt[i].disabled = !!s.live;
var demo = $('demo');
if (demo.dataset.art === 'demo-an') demo.disabled = !!s.live;
}
// Bildschirm anlassen. Ein Pult, das nach 30 s schwarz wird, ist mitten
// im Stream keins. Der Wunsch wird gemerkt; das Handy gibt die Sperre bei
// jedem Wegwischen frei, also wird sie beim Zurueckkommen neu geholt.
var sperre = null;
var wachGewuenscht = false;
try { wachGewuenscht = localStorage.getItem('pult-wach') === '1'; } catch (e) { /* privat */ }
function wachHolen() {
if (!wachGewuenscht || !('wakeLock' in navigator) || document.visibilityState !== 'visible') {
$('wach').setAttribute('aria-pressed', String(!!sperre));
return;
}
navigator.wakeLock.request('screen').then(function (s) {
sperre = s;
$('wach').setAttribute('aria-pressed', 'true');
s.addEventListener('release', function () {
sperre = null;
$('wach').setAttribute('aria-pressed', 'false');
});
}, function () { $('wach').setAttribute('aria-pressed', 'false'); });
}
$('wach').addEventListener('click', function () {
tippen();
if (!('wakeLock' in navigator)) { melden('Dieser Browser kann den Bildschirm nicht wach halten.', 'fehler'); return; }
wachGewuenscht = !sperre;
try { localStorage.setItem('pult-wach', wachGewuenscht ? '1' : '0'); } catch (e) { /* privat */ }
if (sperre) { sperre.release(); sperre = null; melden('Bildschirm darf wieder ausgehen.'); }
else { wachHolen(); melden('Bildschirm bleibt an.', 'ok'); }
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') { wachHolen(); ladeSounds(); }
});
// ------------------------------------------------------------ Sounds
function ladeSounds() {
return holen('/soundboard/liste').then(zeigeSounds, function () { /* Meldung kommt woanders */ });
}
function zeigeSounds(body) {
if (body.sounds) zustand.sounds = body.sounds;
if (typeof body.quellen === 'number') $('quelle-fehlt').hidden = body.quellen > 0;
var raster = $('raster');
raster.textContent = '';
raster.classList.toggle('bearbeiten', zustand.bearbeiten);
$('sounds-leer').hidden = zustand.sounds.length > 0;
$('bearbeiten').hidden = zustand.sounds.length === 0;
if (!zustand.sounds.length && zustand.bearbeiten) setzeBearbeiten(false);
zustand.sounds.forEach(function (s) {
var k = el('button', 'kachel' + (s.emoji ? '' : ' ohne-emoji'));
k.type = 'button';
k.dataset.id = s.id;
k.dataset.farbe = FARBEN.indexOf(s.farbe) !== -1 ? s.farbe : 'lila';
if (s.emoji) k.appendChild(el('span', 'emoji', s.emoji));
k.appendChild(el('span', 'name', s.name));
k.setAttribute('aria-label', (zustand.bearbeiten ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
raster.appendChild(k);
});
}
function knall(id) {
var k = document.querySelector('.kachel[data-id="' + CSS.escape(id) + '"]');
if (!k) return;
k.classList.remove('knall');
void k.offsetWidth; // Animation neu starten, auch beim Doppeltipp
k.classList.add('knall');
}
$('raster').addEventListener('click', function (e) {
var k = e.target.closest('.kachel');
if (!k) return;
tippen();
var id = k.dataset.id;
if (zustand.bearbeiten) { oeffneBlatt(id); return; }
knall(id);
rufen('/soundboard/spielen', { id: id }).then(function (r) {
if (!r.ok) { melden(r.text, 'fehler'); return; }
$('quelle-fehlt').hidden = r.quellen > 0;
if (!r.quellen) melden(r.text, 'fehler');
});
});
$('stopp').addEventListener('click', function () {
tippen();
rufen('/soundboard/stopp').then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
});
function setzeBearbeiten(an) {
zustand.bearbeiten = an;
$('bearbeiten').setAttribute('aria-pressed', String(an));
$('bearbeiten').textContent = an ? 'Fertig' : 'Bearbeiten';
$('raster').classList.toggle('bearbeiten', an);
var kacheln = document.querySelectorAll('.kachel');
for (var i = 0; i < kacheln.length; i++) {
var s = zustand.sounds[i];
if (s) kacheln[i].setAttribute('aria-label', (an ? 'Bearbeiten: ' : 'Abspielen: ') + s.name);
}
}
$('bearbeiten').addEventListener('click', function () { tippen(); setzeBearbeiten(!zustand.bearbeiten); });
// Hochladen: roh im Koerper, eine Datei nach der anderen. Parallel waere
// schneller, aber dann kommt die Reihenfolge durcheinander.
$('datei').addEventListener('change', function (e) {
var dateien = Array.prototype.slice.call(e.target.files || []);
e.target.value = '';
var weiter = Promise.resolve();
dateien.forEach(function (d, i) {
weiter = weiter.then(function () {
melden((dateien.length > 1 ? (i + 1) + '/' + dateien.length + ' · ' : '') + d.name + ' wird hochgeladen …');
return rufen('/soundboard/neu?name=' + encodeURIComponent(d.name), null, d).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.sounds) zeigeSounds(r);
// Der erste Sound geht gleich ins Bearbeiten: ein Emoji macht aus
// einer Liste von Dateinamen erst ein Pult.
if (r.ok && r.sound && dateien.length === 1) oeffneBlatt(r.sound.id);
});
});
});
});
// ------------------------------------------------------------ Blatt
var blatt = $('blatt');
var offenId = null;
var probe = null;
(function baueBlatt() {
var schnell = $('b-schnell');
SCHNELL.forEach(function (em) {
var b = el('button', '', em);
b.type = 'button';
b.addEventListener('click', function () { $('b-emoji').value = em; tippen(); });
schnell.appendChild(b);
});
var farben = $('b-farben');
FARBEN.forEach(function (f) {
var b = el('button', 'farbe');
b.type = 'button';
b.dataset.farbe = f;
b.setAttribute('role', 'radio');
b.setAttribute('aria-label', f);
b.addEventListener('click', function () { waehleFarbe(f); tippen(); });
farben.appendChild(b);
});
})();
function waehleFarbe(f) {
var alle = $('b-farben').children;
for (var i = 0; i < alle.length; i++) alle[i].setAttribute('aria-checked', String(alle[i].dataset.farbe === f));
blatt.dataset.farbe = f;
}
function oeffneBlatt(id) {
var s = zustand.sounds.find(function (x) { return x.id === id; });
if (!s) return;
offenId = id;
$('b-name').value = s.name;
$('b-emoji').value = s.emoji || '';
$('b-laut').value = Math.round((s.laut ?? 0.8) * 100);
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
waehleFarbe(s.farbe);
$('b-loeschen').textContent = 'Löschen';
$('b-loeschen').dataset.sicher = '';
if (typeof blatt.showModal === 'function') blatt.showModal(); else blatt.setAttribute('open', '');
}
function schliesseBlatt() {
if (probe) { probe.pause(); probe = null; }
offenId = null;
if (blatt.open) blatt.close();
}
$('b-laut').addEventListener('input', function () {
$('b-laut-zahl').textContent = $('b-laut').value + ' %';
if (probe) probe.volume = Number($('b-laut').value) / 100;
});
$('b-hoeren').addEventListener('click', function () {
if (!offenId) return;
if (probe) { probe.pause(); probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; return; }
probe = new Audio('/soundboard/datei/' + encodeURIComponent(offenId));
probe.volume = Number($('b-laut').value) / 100;
$('b-hoeren').textContent = 'Anhören stoppen';
probe.onended = function () { probe = null; $('b-hoeren').textContent = 'Am Handy anhören'; };
probe.play().catch(function () { melden('Abspielen am Handy ging nicht.', 'fehler'); });
});
function verschieben(um) {
if (!offenId) return;
tippen();
rufen('/soundboard/verschieben', { id: offenId, um: String(um) }).then(function (r) {
if (r.sounds) zeigeSounds(r);
});
}
$('b-vor').addEventListener('click', function () { verschieben(-1); });
$('b-zurueck').addEventListener('click', function () { verschieben(1); });
$('b-loeschen').addEventListener('click', function () {
var b = $('b-loeschen');
// Zweimal tippen statt confirm(): ein Dialog des Browsers sieht am Handy
// aus wie eine Fehlermeldung, und er haelt die Seite an.
if (!b.dataset.sicher) { b.dataset.sicher = '1'; b.textContent = 'Wirklich löschen?'; tippen(); return; }
var id = offenId;
schliesseBlatt();
rufen('/soundboard/loeschen', { id: id }).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.sounds) zeigeSounds(r);
});
});
$('b-abbrechen').addEventListener('click', schliesseBlatt);
blatt.addEventListener('close', function () { if (probe) { probe.pause(); probe = null; } });
blatt.addEventListener('click', function (e) { if (e.target === blatt) schliesseBlatt(); });
$('blatt-form').addEventListener('submit', function (e) {
e.preventDefault();
var id = offenId;
if (!id) return;
var felder = {
id: id,
name: $('b-name').value,
emoji: $('b-emoji').value,
farbe: blatt.dataset.farbe || 'lila',
laut: String(Number($('b-laut').value) / 100),
};
schliesseBlatt();
rufen('/soundboard/aendern', felder).then(function (r) {
if (!r.ok) melden(r.text, 'fehler');
if (r.sounds) zeigeSounds(r);
});
});
// ------------------------------------------------------------ OBS
function zeigeObs(o) {
zustand.obs = o;
var fehlt = !o.pc || !o.obs;
$('obs-fehlt').hidden = !fehlt;
$('obs-da').hidden = fehlt;
if (!o.pc) {
$('obs-fehlt-titel').textContent = 'Stream-PC nicht verbunden';
$('obs-fehlt-text').textContent = 'Sobald abdulive auf dem Stream-PC läuft, meldet er sich hier ' +
'und du tippst einmal auf „Freigeben“. Läuft er schon, ist er vielleicht aus oder offline.';
} else if (!o.obs) {
$('obs-fehlt-titel').textContent = 'OBS ist nicht erreichbar';
$('obs-fehlt-text').textContent = 'Der Stream-PC ist da, aber OBS nicht. Läuft OBS, und ist ' +
'obs-websocket an (Werkzeuge → WebSocket-Servereinstellungen)?';
}
if (fehlt) return;
var szenen = $('szenen');
szenen.textContent = '';
o.szenen.forEach(function (name) {
var b = el('button', 'szene' + (name === o.aktuell ? ' aktiv' : ''));
b.type = 'button';
b.dataset.szene = name;
b.appendChild(el('span', 'szene-name', name));
b.appendChild(el('span', 'marke-an', 'Im Bild'));
szenen.appendChild(b);
});
if (!o.szenen.length) szenen.appendChild(el('p', 'klein', 'OBS meldet keine Szenen.'));
$('clip').disabled = !o.puffer;
$('clip-hinweis').textContent = o.puffer
? 'Sichert die letzten Sekunden aus dem Wiedergabepuffer, wie !clip im Chat – ohne Abklingzeit.'
: 'Der Wiedergabepuffer läuft nicht. In OBS unten rechts „Wiedergabepuffer starten“.';
var ton = $('ton');
ton.textContent = '';
o.ton.forEach(function (t) {
var b = el('button', 'ton-zeile' + (t.stumm ? ' stumm' : ''));
b.type = 'button';
b.dataset.quelle = t.name;
b.dataset.stumm = t.stumm ? '1' : '0';
b.innerHTML = t.stumm
? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM16 9l5 6M21 9l-5 6"/></svg>'
: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4zM15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg>';
b.appendChild(el('span', 'ton-name', t.name));
b.appendChild(el('span', 'ton-stand', t.stumm ? 'stumm' : 'an'));
ton.appendChild(b);
});
if (!o.ton.length) ton.appendChild(el('p', 'klein', 'Keine Toneingänge gefunden.'));
}
function obsRuf(knopf, felder) {
tippen();
return mitWarten(knopf, rufen('/pult/obs', felder)).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
return r;
});
}
$('szenen').addEventListener('click', function (e) {
var b = e.target.closest('.szene');
if (!b || b.classList.contains('aktiv')) return;
obsRuf(b, { was: 'szene', szene: b.dataset.szene }).then(function (r) {
// Sofort umfaerben, nicht erst auf den naechsten Stand warten. Der
// Stand vom PC kommt hinterher und rueckt es gerade, falls noetig.
if (r.ok) zeigeObs(Object.assign({}, zustand.obs, { aktuell: b.dataset.szene }));
});
});
$('clip').addEventListener('click', function () { obsRuf($('clip'), { was: 'clip' }); });
$('ton').addEventListener('click', function (e) {
var b = e.target.closest('.ton-zeile');
if (!b) return;
obsRuf(b, { was: 'stumm', quelle: b.dataset.quelle, stumm: b.dataset.stumm === '1' ? '0' : '1' });
});
// ------------------------------------------------------------ Freigabe
// Ein Stream-PC, der sich zum ersten Mal meldet, wartet hier auf einen
// Tipp. Name und Adresse kommen vom PC selbst - eine Anfrage, die man nicht
// erwartet, lehnt man ab.
function zeigeAnfragen(liste) {
var box = $('anfragen');
box.textContent = '';
$('nav-fern').hidden = !liste.length;
liste.forEach(function (a) {
var k = el('div', 'anfrage');
k.appendChild(el('h2', '', 'Stream-PC „' + a.name + '“ möchte sich verbinden'));
var seit = new Date(a.seit);
k.appendChild(el('p', '', 'Von ' + a.adresse + ', seit ' +
seit.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +
' Uhr. Danach steuert das Pult OBS auf diesem Rechner.'));
var reihe = el('div', 'reihe');
var ja = el('button', 'knopf primaer', 'Freigeben');
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = a.id;
var nein = el('button', 'knopf', 'Ablehnen');
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = a.id;
reihe.appendChild(ja); reihe.appendChild(nein);
k.appendChild(reihe);
box.appendChild(k);
});
}
function ladeAnfragen() {
holen('/pult/fern').then(function (b) { zeigeAnfragen(b.anfragen || []); }, function () { /* still */ });
}
$('anfragen').addEventListener('click', function (e) {
var b = e.target.closest('button[data-was]');
if (!b) return;
tippen();
mitWarten(b, rufen('/pult/fern', { was: b.dataset.was, id: b.dataset.id })).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.anfragen) zeigeAnfragen(r.anfragen);
});
});
$('trennen').addEventListener('click', function () {
var b = $('trennen');
if (!b.dataset.sicher) {
b.dataset.sicher = '1';
b.textContent = 'Wirklich trennen? Danach neu freigeben.';
setTimeout(function () { b.dataset.sicher = ''; b.textContent = 'Stream-PC trennen'; }, 4000);
return;
}
b.dataset.sicher = '';
b.textContent = 'Stream-PC trennen';
rufen('/pult/fern', { was: 'trennen' }).then(function (r) { melden(r.text, r.ok ? 'ok' : 'fehler'); });
});
// ------------------------------------------------------------ Liegestuetze
function zeigeLz(st) {
if (!st) return;
$('lz-karte').hidden = false;
if (typeof st.offen === 'number') $('lz-offen').textContent = st.offen.toLocaleString('de-DE');
if (typeof st.gemacht === 'number') $('lz-gemacht').textContent = st.gemacht.toLocaleString('de-DE');
}
document.querySelector('.lz-knoepfe').addEventListener('click', function (e) {
var b = e.target.closest('button[data-um]');
if (!b) return;
tippen();
mitWarten(b, rufen('/liegestuetze', { was: 'aendern', wert: b.dataset.um })).then(function (r) {
zeigeLz(r);
if (!r.ok) melden(r.text, 'fehler');
});
});
// ------------------------------------------------------------ Vorlesen
function zeigeWartend(liste) {
var box = $('wartend');
box.textContent = '';
$('tts-anzahl').hidden = !liste.length;
$('tts-anzahl').textContent = String(liste.length);
$('nav-tts').hidden = !liste.length;
$('alle-ablehnen').hidden = liste.length < 2;
if (!liste.length) { box.appendChild(el('div', 'leer-zeile', 'Nichts wartet auf Freigabe.')); return; }
liste.forEach(function (w) {
var n = el('div', 'nachricht');
n.appendChild(el('div', 'wer', w.name));
n.appendChild(el('div', 'was', w.text));
var reihe = el('div', 'reihe');
var ja = el('button', 'knopf primaer', 'Vorlesen');
ja.type = 'button'; ja.dataset.was = 'freigeben'; ja.dataset.id = w.id;
var nein = el('button', 'knopf', 'Ablehnen');
nein.type = 'button'; nein.dataset.was = 'ablehnen'; nein.dataset.id = w.id;
reihe.appendChild(ja); reihe.appendChild(nein);
n.appendChild(reihe);
box.appendChild(n);
});
}
function ladeWartend() {
if (!zustand.ttsAn) return;
holen('/tts/wartend').then(function (b) { zeigeWartend(b.wartend || []); }, function () { /* still */ });
}
function ttsRuf(was, id) {
tippen();
return rufen('/tts', { was: was, id: id || '' }).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (r.wartend) zeigeWartend(r.wartend);
});
}
$('wartend').addEventListener('click', function (e) {
var b = e.target.closest('button[data-was]');
if (b) ttsRuf(b.dataset.was, b.dataset.id);
});
$('alle-ablehnen').addEventListener('click', function () { ttsRuf('alle-ablehnen'); });
// Der Panikschalter. Ohne Vorlesen haelt er wenigstens die Sounds an.
$('still').addEventListener('click', function () {
tippen();
if (zustand.ttsAn) { ttsRuf('still'); return; }
rufen('/soundboard/stopp').then(function (r) { melden(r.ok ? 'Alles still.' : r.text, r.ok ? 'ok' : 'fehler'); });
});
// ------------------------------------------------------------ Chat
// Nur lesend. Die Nachrichten kommen ueber denselben Ereignisstrom wie bei
// der Chat-Browser-Source; das Pult schreibt nie in den Chat und braucht
// dafuer weder Bot-Konto noch weitere Rechte bei Twitch.
var CHAT_MAX = 250;
var chatListe = $('chat-liste');
/** Steht die Liste am Ende? Dann wandert sie mit, sonst nicht. */
var chatKlebt = true;
/* Wie in chat.html: dunkle Twitch-Farben aufhellen statt verwerfen. */
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);
return '#' + [r, g, b].map(function (v) {
return Math.min(255, Math.round(v * f)).toString(16).padStart(2, '0');
}).join('');
}
/** Text mit Emotes als Knoten. Ersetzt wird der Name als ganzes Wort,
* wie in client.js - Twitch liefert keine Positionen. */
function emoteKnoten(ziel, text, emotes) {
var ids = {};
(emotes || []).forEach(function (e) { if (e && e.id && e.name) ids[e.name] = e.id; });
text.split(/(\s+)/).forEach(function (wort) {
if (!wort) return;
if (!Object.prototype.hasOwnProperty.call(ids, wort)) {
ziel.appendChild(document.createTextNode(wort));
return;
}
var img = el('img', 'emote');
img.src = 'https://static-cdn.jtvnw.net/emoticons/v2/' +
encodeURIComponent(ids[wort]) + '/default/dark/2.0';
img.alt = wort;
img.title = wort;
ziel.appendChild(img);
});
}
/** GIFs kommen mit Zeichenbereich und ersetzen ihren Platzhalter im Text. */
function textKnoten(ziel, text, gifs, emotes) {
var zeichen = Array.from(text);
var pos = 0;
(gifs || []).slice().sort(function (a, b) { return a.from - b.from; }).forEach(function (g) {
if (typeof g.url !== 'string' || g.url.slice(0, 8) !== 'https://') return;
if (g.from > pos) emoteKnoten(ziel, zeichen.slice(pos, g.from).join(''), emotes);
var img = el('img', 'gif');
img.src = g.url;
img.alt = 'GIF';
img.onerror = function () { img.replaceWith(document.createTextNode('[GIF]')); };
ziel.appendChild(img);
pos = Math.max(pos, g.to + 1);
});
if (pos < zeichen.length) emoteKnoten(ziel, zeichen.slice(pos).join(''), emotes);
}
function uhrzeit(ts) {
var d = ts ? new Date(ts) : new Date();
if (isNaN(d.getTime())) d = new Date();
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function chatSichtbar() {
return geteilt.matches || $('tab-chat').classList.contains('an');
}
function nachUnten() {
chatListe.scrollTop = chatListe.scrollHeight;
chatKlebt = true;
$('chat-runter').hidden = true;
}
function chatGesehen() {
$('nav-chat-punkt').hidden = true;
nachUnten();
}
function chatAnhaengen(zeile, still) {
chatListe.appendChild(zeile);
while (chatListe.children.length > CHAT_MAX) chatListe.firstChild.remove();
$('chat-leer').hidden = true;
if (chatKlebt) nachUnten();
else $('chat-runter').hidden = false;
if (!still && !chatSichtbar()) $('nav-chat-punkt').hidden = false;
}
function chatNachricht(ev, still) {
if (!ev || !ev.id) return;
if (chatListe.querySelector('[data-id="' + CSS.escape(ev.id) + '"]')) return;
var z = el('div', 'cz' + (ev.first ? ' erstmals' : '') + (ev.bits > 0 ? ' bits' : ''));
z.dataset.id = ev.id;
z.dataset.user = ev.userId || '';
z.appendChild(el('span', 'zeit', uhrzeit(ev.ts)));
(ev.badges || []).forEach(function (b) {
// Nur Twitchs eigene Bildadressen, wie in chat.html.
if (!b || !/^https:\/\/static-cdn\.jtvnw\.net\//.test(b.url)) return;
var img = el('img', 'abz');
img.src = b.url;
img.alt = b.title || '';
img.title = b.title || '';
z.appendChild(img);
});
var wer = el('span', 'wer', ev.name || ev.login || '?');
wer.style.color = lesbar(ev.color);
z.appendChild(wer);
z.appendChild(document.createTextNode(': '));
var text = el('span', 'text');
textKnoten(text, String(ev.text || ''), ev.gifs, ev.emotes);
z.appendChild(text);
chatAnhaengen(z, still);
}
/** Was im Stream passiert, als eigene Zeile zwischen den Nachrichten.
* Erstchatter und Bits fehlen bewusst: die zeigt schon die Nachricht. */
function alarmText(a) {
var n = a.name || 'Jemand';
switch (a.type) {
case 'sub': return n + ' hat abonniert' + (a.tier ? ' (Stufe ' + String(a.tier).charAt(0) + ')' : '');
case 'resub': return n + ' abonniert weiter' + (a.months ? ', seit ' + a.months + ' Monaten' : '');
case 'subgift': return n + ' verschenkt ein Abo' + (a.recipient ? ' an ' + a.recipient : '');
case 'massgift': return n + ' verschenkt ' + (a.amount || 'mehrere') + ' Abos';
case 'raid': return 'Raid von ' + n + (a.amount ? ' mit ' + a.amount + ' Zuschauern' : '');
case 'follow': return n + ' folgt jetzt';
case 'reward': return n + ' löst „' + (a.reward || 'Belohnung') + '“ ein';
default: return null;
}
}
function chatAlarm(a) {
var text = alarmText(a);
if (!text) return;
var z = el('div', 'cz system');
z.appendChild(el('span', 'zeit', uhrzeit()));
z.appendChild(document.createTextNode(text + (a.message ? ': ' + a.message : '')));
chatAnhaengen(z);
}
/** Von einem Mod geloescht: durchstreichen statt verschwinden lassen,
* damit man am Pult sieht, dass da etwas war. */
function chatLoeschen(ev) {
var zeilen = chatListe.querySelectorAll('.cz:not(.system)');
for (var i = 0; i < zeilen.length; i++) {
var z = zeilen[i];
if ((!ev.messageId && !ev.userId) ||
(ev.messageId && z.dataset.id === ev.messageId) ||
(ev.userId && z.dataset.user === ev.userId)) z.classList.add('weg');
}
}
chatListe.addEventListener('scroll', function () {
chatKlebt = chatListe.scrollHeight - chatListe.scrollTop - chatListe.clientHeight < 40;
if (chatKlebt) $('chat-runter').hidden = true;
}, { passive: true });
// Emotes und GIFs laden nach und machen die Zeile hoeher. Ohne das
// rutscht die neueste Nachricht unter den Rand.
chatListe.addEventListener('load', function () { if (chatKlebt) nachUnten(); }, true);
$('chat-runter').addEventListener('click', function () { tippen(); nachUnten(); });
geteilt.addEventListener('change', function () {
if (geteilt.matches && $('tab-chat').classList.contains('an')) zeigeReiter('szenen');
if (geteilt.matches) chatGesehen();
});
// ------------------------------------------------------------ Test
function baueTest(knoepfe, demoLaeuft) {
var gruppen = $('test-gruppen');
gruppen.textContent = '';
var namen = [];
knoepfe.forEach(function (k) { if (namen.indexOf(k.gruppe) === -1) namen.push(k.gruppe); });
namen.forEach(function (g) {
var box = el('div', 'gruppe');
box.appendChild(el('h3', 'abschnitt', g));
var reihe = el('div', 'knoepfe');
knoepfe.filter(function (k) { return k.gruppe === g; }).forEach(function (k) {
var b = el('button', 'knopf', k.label);
b.type = 'button';
b.dataset.art = k.art;
if (k.nurOffline) b.dataset.offline = '1';
reihe.appendChild(b);
});
box.appendChild(reihe);
gruppen.appendChild(box);
});
setzeDemo(demoLaeuft);
}
function setzeDemo(laeuft) {
var d = $('demo');
d.dataset.art = laeuft ? 'demo-aus' : 'demo-an';
d.textContent = laeuft ? 'Vorführung stoppen' : 'Vorführung starten';
d.disabled = !laeuft && zustand.live;
}
function testRuf(knopf) {
tippen();
mitWarten(knopf, rufen('/test', { art: knopf.dataset.art })).then(function (r) {
melden(r.text, r.ok ? 'ok' : 'fehler');
if (typeof r.demo === 'boolean') setzeDemo(r.demo);
});
}
$('test-gruppen').addEventListener('click', function (e) {
var b = e.target.closest('button[data-art]');
if (b && !b.disabled) testRuf(b);
});
$('demo').addEventListener('click', function () { testRuf($('demo')); });
// ------------------------------------------------------------ Strom
var quelle = null;
var zuletzt = Date.now();
function verbinde() {
if (quelle) { try { quelle.close(); } catch (e) { /* zu */ } }
quelle = new EventSource('/events?quelle=pult');
zuletzt = Date.now();
quelle.onopen = function () { setzeNetz(true); };
quelle.onerror = function () {
setzeNetz(false);
// EventSource sagt nicht, ob es an der Anmeldung liegt. Eine kurze
// Frage klaert das, sonst stuende "keine Verbindung" fuer immer da.
holen('/soundboard/liste').then(function () { /* nur Netz */ }, function () { /* abgemeldet() hat schon */ });
};
quelle.onmessage = function (e) {
zuletzt = Date.now();
setzeNetz(true);
var ev;
try { ev = JSON.parse(e.data); } catch (err) { return; }
switch (ev.kind) {
case 'state':
zeigeStream(ev.stream);
if (ev.obs) zeigeObs(ev.obs);
if (ev.liegestuetze && !$('lz-karte').hidden) zeigeLz(ev.liegestuetze);
// Nach dem Neuverbinden: was fehlt, nachtragen. Schon Gezeigtes
// bleibt stehen, sonst waere der Rueckblick bei jedem Abriss weg.
(ev.chat || []).forEach(function (m) { chatNachricht(m, true); });
break;
case 'chat': chatNachricht(ev, false); break;
case 'alert': chatAlarm(ev); break;
case 'clear': chatLoeschen(ev); break;
case 'stream': zeigeStream(ev); break;
case 'obs': zeigeObs(ev); break;
case 'liegestuetze': if (!$('lz-karte').hidden) zeigeLz(ev); break;
case 'sound': knall(ev.id); break;
case 'sounds-geaendert': ladeSounds(); break;
case 'tts': ladeWartend(); break;
case 'fern-anfrage': ladeAnfragen(); break;
}
};
}
function setzeNetz(an) {
$('netz').classList.toggle('an', an);
var banner = $('banner');
banner.hidden = an;
if (!an) banner.textContent = 'Keine Verbindung zum Hub – verbinde neu …';
}
// Wachhund wie in client.js: der Hub schickt alle 15 s einen Herzschlag.
// Am Handy reisst die Leitung beim Wegwischen ab, ohne dass es jemand merkt.
setInterval(function () {
if (Date.now() - zuletzt > 45000) verbinde();
}, 10000);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && Date.now() - zuletzt > 20000) verbinde();
});
// ------------------------------------------------------------ Start
holen('/pult/knoepfe').then(function (b) {
baueTest(b.knoepfe || [], !!b.demo);
zustand.ttsAn = !!b.tts;
$('tts-karte').hidden = !b.tts;
if (b.liegestuetze) zeigeLz(b.liegestuetze);
ladeWartend();
}, function () { /* abgemeldet oder weg - zeigt sich oben */ });
ladeSounds();
ladeAnfragen();
verbinde();
wachHolen();
// Die Liste der Wartenden kommt nicht ueber den Strom (dort steht nur, was
// vorgelesen wird). Also nachsehen, solange das Pult offen ist.
setInterval(function () {
if (document.visibilityState === 'visible') ladeWartend();
}, 4000);
// Ob die Sound-Quelle in OBS offen ist, aendert sich ohne Ereignis.
setInterval(function () {
if (document.visibilityState === 'visible') ladeSounds();
}, 20000);
// Ein Reiter in der Adresse (Verknuepfung "Szenen" am App-Symbol) geht vor
// dem zuletzt benutzten.
var gemerkt = location.hash.slice(1) || null;
if (!gemerkt) { try { gemerkt = localStorage.getItem('pult-reiter'); } catch (e) { /* privat */ } }
// Am Tablet ist der naheliegende Anfang Szenen neben dem Chat.
if (!gemerkt && geteilt.matches) gemerkt = 'szenen';
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function (e) {
console.warn('[pult] Service Worker nicht registriert', e);
});
}
})();
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Soundboard</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Eine reine Tonquelle. Im Stream ist nichts zu sehen - ein Sound, der
* zusaetzlich seinen Namen ins Bild schreibt, nimmt ihm die Pointe.
*
* Mit ?probe=1 steht unten, was gerade klingt. Damit laesst sich im Browser
* pruefen, ob ein Druck am Handy hier ankommt, auch ohne Lautsprecher. */
#liste {
position: fixed;
left: 12px; right: 12px; bottom: 12px;
display: none;
flex-direction: column;
gap: 6px;
font: 600 16px/1.2 var(--font-zahl);
}
body.probe #liste { display: flex; }
.laeuft {
padding: 8px 12px;
border-radius: 10px;
background: var(--tinte-klar);
border-left: 4px solid var(--lila);
}
.laeuft.fehler { border-left-color: var(--rot); }
</style>
</head>
<body>
<div id="liste"></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var liste = document.getElementById('liste');
var schluessel = Overlay.param('key', '');
/* Lautstaerke der ganzen Quelle. Besser im OBS-Mixer regeln; das hier ist
* fuer den Fall, dass alle Sounds gemeinsam zu laut aufgenommen sind. */
var gesamt = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
/* Mehr gleichzeitig klingt nicht mehr nach Soundboard, sondern nach Unfall.
* Der aelteste Sound macht dem neuen Platz. */
var maxGleichzeitig = Math.max(1, Overlay.param('max', 4));
var laufend = [];
function zeile(name, fehler) {
var d = document.createElement('div');
d.className = 'laeuft' + (fehler ? ' fehler' : '');
d.textContent = name;
liste.appendChild(d);
return d;
}
function aufhoeren(eintrag) {
var i = laufend.indexOf(eintrag);
if (i !== -1) laufend.splice(i, 1);
try { eintrag.ton.pause(); } catch (e) { /* egal */ }
eintrag.ton.removeAttribute('src');
if (eintrag.zeile.parentNode) eintrag.zeile.parentNode.removeChild(eintrag.zeile);
}
function alleStopp() {
laufend.slice().forEach(aufhoeren);
}
Overlay.on('sound', function (ev) {
while (laufend.length >= maxGleichzeitig) aufhoeren(laufend[0]);
var ton = new Audio('/soundboard/datei/' + encodeURIComponent(ev.id) +
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
var laut = typeof ev.laut === 'number' ? ev.laut : 0.8;
ton.volume = Math.max(0, Math.min(1, laut * gesamt));
var eintrag = { ton: ton, zeile: zeile(ev.name || 'Sound') };
laufend.push(eintrag);
ton.onended = function () { aufhoeren(eintrag); };
ton.onerror = function () {
console.error('[sounds] nicht abspielbar', ev.name, ton.error && ton.error.code);
eintrag.zeile.classList.add('fehler');
setTimeout(function () { aufhoeren(eintrag); }, 3000);
};
// Notbremse: ein Sound, der nie "zu Ende" meldet, belegte sonst fuer
// immer einen Platz.
setTimeout(function () { aufhoeren(eintrag); }, 5 * 60 * 1000);
var p = ton.play();
// Ein normaler Browser spielt erst nach einem Klick in die Seite. OBS
// nicht - wer hier im Browser testet, sieht es an der roten Zeile.
if (p && p.catch) p.catch(function (e) {
console.error('[sounds] Wiedergabe verweigert', e && e.name);
eintrag.zeile.textContent = (ev.name || 'Sound') + ' (erst in die Seite klicken)';
eintrag.zeile.classList.add('fehler');
setTimeout(function () { aufhoeren(eintrag); }, 3000);
});
});
Overlay.on('sound-stopp', alleStopp);
// Der Panikschalter gilt auch hier.
Overlay.on('still', alleStopp);
// Nach dem Neuverbinden nichts nachholen: ein Sound zur falschen Zeit ist
// schlimmer als keiner.
Overlay.on('state', function () { });
})();
</script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
/* Service Worker fuer das Pult.
*
* Er macht genau zwei Dinge: er ist da, damit sich das Pult als App
* installieren laesst, und er haelt die Huelle des Pults vor, damit es ohne
* Netz wenigstens aufgeht und "keine Verbindung" sagen kann statt einer
* leeren Fehlerseite des Browsers.
*
* Immer zuerst das Netz: nach dem Ausrollen soll sofort die neue Fassung
* laufen, nicht die aus dem Speicher. Alles, was nicht auf der Liste steht -
* Ereignisstrom, Knoepfe, Sounds, die Browser-Sources fuer OBS -, fasst er
* nicht an. Ein Pult, das einen Szenenwechsel aus dem Speicher beantwortet,
* waere schlimmer als gar keins. */
var SPEICHER = 'pult-v1';
var HUELLE = ['/pult', '/pult.css', '/pult.js', '/manifest.webmanifest',
'/icons/pult-192.png', '/icons/apple-touch-icon.png'];
self.addEventListener('install', function () { self.skipWaiting(); });
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (namen) {
return Promise.all(namen.filter(function (n) { return n !== SPEICHER; })
.map(function (n) { return caches.delete(n); }));
}).then(function () { return self.clients.claim(); }));
});
self.addEventListener('fetch', function (e) {
var url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
if (HUELLE.indexOf(url.pathname) === -1) return;
e.respondWith(fetch(e.request).then(function (res) {
// Nur Gelungenes merken. Die Anmeldeseite (401) gehoert nicht in den
// Speicher, sonst steht sie beim naechsten Funkloch statt des Pults da.
if (res.ok) {
var kopie = res.clone();
caches.open(SPEICHER).then(function (c) { c.put(url.pathname, kopie); });
}
return res;
}, function () {
return caches.match(url.pathname).then(function (r) {
return r || new Response('Keine Verbindung zum Hub.', {
status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' },
});
});
}));
});
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/raid-scout
Raid-Ziele finden und bewerten, Raid-Historie und Erwiderungs-Erinnerung.
Spezifikation: [docs/projects/raid-network.md](../../docs/projects/raid-network.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/recap
Stream-Zusammenfassung nach Streamende als Discord-Post.
Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md)
-5
View File
@@ -1,5 +0,0 @@
# @abdulive/vod-chapters
Kapitel-Timestamps aus Kategorie- und Szenenwechseln.
Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md)
+8 -33
View File
@@ -95,34 +95,6 @@ broadcaster:
mastodon: false mastodon: false
instagramStoryImage: false instagramStoryImage: false
community:
greetings:
enabled: true
minSecondsBetween: 20
maxPerStream: 10
returningAfterDays: 21
milestones: [5, 10, 25, 50, 100]
suppressWhileStreamerSpeaking: true
overlayHint: true
raidScout:
minViewers: 5
maxViewers: 30
minUptimeMinutes: 30
maxUptimeMinutes: 300
languages: [de]
topN: 5
recap:
enabled: true
delayMinutesAfterOffline: 10
minStreamMinutes: 20
hideViewerNumbersIfWeak: true
topChatters: 3
topClips: 2
loyalty:
enabled: false # spaetere Ausbaustufe
# Kanalpunkt-Belohnungen und was sie ausloesen. # Kanalpunkt-Belohnungen und was sie ausloesen.
# #
# Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein- # Zugeordnet wird ueber den Titel der Belohnung auf Twitch (Gross-/Klein-
@@ -130,7 +102,7 @@ loyalty:
# Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe # Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe
# gelassen - nicht jede Belohnung geht das Toolkit etwas an. # gelassen - nicht jede Belohnung geht das Toolkit etwas an.
# #
# Aktionen: konfetti | emote-regen | einblendung | tts | nichts # Aktionen: konfetti | emote-regen | einblendung | tts | liegestuetze | nichts
rewards: rewards:
enabled: false # aus, bis hier bewusst Belohnungen stehen enabled: false # aus, bis hier bewusst Belohnungen stehen
belohnungen: [] belohnungen: []
@@ -141,6 +113,9 @@ rewards:
# - titel: Nachricht vorlesen # - titel: Nachricht vorlesen
# aktion: tts # aktion: tts
# maxZeichen: 150 # maxZeichen: 150
# - titel: 5 Liegestütze
# aktion: liegestuetze
# anzahl: 5 # so viele kommen je Einloesung dazu
tts: tts:
# Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte # Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte
# sammeln sich beim Zugucken von allein an und sind damit kaum eine # sammeln sich beim Zugucken von allein an und sind damit kaum eine
@@ -155,10 +130,10 @@ rewards:
# Gesprochen wird im Hub mit Piper, nicht im Browser - dort haengt die # Gesprochen wird im Hub mit Piper, nicht im Browser - dort haengt die
# Stimme am Rechner, der streamt. Name einer Datei unter models/piper/ # Stimme am Rechner, der streamt. Name einer Datei unter models/piper/
# (ohne .onnx) oder ein absoluter Pfad. Einrichten: scripts/install-piper.sh # (ohne .onnx) oder ein absoluter Pfad. Einrichten: scripts/install-piper.sh
stimme: de_DE-mls-medium stimme: de_DE-thorsten-medium
# mls hat 236 Sprecher in einem Modell; hier steht die Nummer (0-235). # Nur bei Stimmen mit mehreren Sprechern, z. B. de_DE-mls-medium (0-235).
# Bei Stimmen mit nur einem Sprecher weglassen. # Die klang im Stream allerdings verwaschen.
sprecher: 0 # sprecher: 0
# Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst # Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran. # zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
+11 -12
View File
@@ -2,6 +2,9 @@
## Bild ## Bild
Stand vor dem Umzug in den Hub (ADR-0008). Raid-Scout, Auswertung,
VOD-Kapitel, Community-Gedaechtnis und Recap sind seit 2026-10-27 gestrichen.
``` ```
Twitch OBS Twitch OBS
┌──────────────────┐ ┌───────────┐ ┌──────────────────┐ ┌───────────┐
@@ -20,19 +23,15 @@
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ SQLite │ │ Event-Bus │ │ SQLite │ │ Event-Bus │
│ data/db/ │ │ (in-proc + WS 4790) │ │ data/db/ │ │ (in-proc + WS 4790) │
└────────┬────────┘ └──────────┬───────────┘ └─────────────────┘ └──────────┬───────────┘
│ │
┌───────────┬───────┼───────────┬───────────┼───────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
dashboard recap raid-scout vod-chapters community overlay- clipper
server │
│ │
overlay-web ffmpeg
(Browser- whisper
Source) │
data/clips
│ │
broadcaster ┌─────────┴─────────┐
▼ ▼
overlay-server clipper-Kette
(Pult, Sounds) ffmpeg, whisper
│ │
overlay-web data/clips
(Browser-Source)
``` ```
## Prozesse ## Prozesse
+4 -17
View File
@@ -29,24 +29,11 @@ und Chat sowie Zuschauerzahlen vollständig in der DB stehen.
**Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird **Fertig, wenn:** Erstchatter werden erkannt und begrüßt, Stream-Start wird
automatisch angekündigt. automatisch angekündigt.
## M4 — Auswertung (Woche 5–6) ## M4 bis M6 — gestrichen
- [ ] `apps/dashboard`: Stream-Zeitachse, Abgangs-Analyse Auswertung (`apps/dashboard`), Netzwerk (`apps/raid-scout`) und Recap
- [ ] Titel-/Kategorie-Performance mit Stichprobenhinweis (`apps/recap`) sind seit 2026-10-27 gestrichen, samt Spezifikationen. Der Hub
- [ ] Kohortenansicht neu vs. wiederkehrend bleibt bei OBS-Anbindung, Overlay, Pult, Kanalpunkten und Discord.
- [ ] Betriebsstatus-Seite
- [ ] Spike-Detektor an echten Daten kalibrieren und scharf schalten
## M5 — Netzwerk (Woche 7)
- [ ] `apps/raid-scout`: Kandidatensuche, Scoring, `!raidziel`
- [ ] Raid-Historie und Erwiderungs-Erinnerung
- [ ] Overlay-Panel für das Streamende
## M6 — Recap (Woche 8)
- [ ] `apps/recap`: Discord-Post nach Streamende
- [ ] Clip-Einbettung, Erstchatter-Begrüßung, Wochenvergleich
## Später ## Später
+2 -2
View File
@@ -260,8 +260,8 @@ in unter zwei Minuten entscheiden, welche Clips hochgeladen werden.
Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine Der IRC-Client aus S1 liegt ohnehin da, das `first-msg`-Tag ebenso. Eine
Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit Begrüßung für Erstchatter, eine für Rückkehrer nach mehr als 21 Tagen, mit
Cooldown und Obergrenze pro Stream. Details in Cooldown und Obergrenze pro Stream. (Die ausführliche Spezifikation lag in
[`projects/community-memory.md`](projects/community-memory.md). `projects/community-memory.md` und ist mit dem Community-Gedächtnis gestrichen.)
Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt. Diese Scheibe steht am Ende, weil sie unabhängig ist und notfalls entfällt.
Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt Sie kostet fast nichts und ist das Einzige im MVP, das die Zuschauer direkt
+183 -22
View File
@@ -1,6 +1,6 @@
# Übergabe # Übergabe
Stand **2026-09-23**, letztes gebautes Paket 0.5.4. Wer hier weitermacht, Stand **2026-10-27**, letztes gebautes Paket 0.5.7. Wer hier weitermacht,
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
selbst gibt es das [Handbuch](handbuch/handbuch.pdf). selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
@@ -20,7 +20,7 @@ Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
| S5 Onboarding + Sichtung | offen | | | S5 Onboarding + Sichtung | offen | |
| S6 Erstchatter-Begrüßung | offen | | | S6 Erstchatter-Begrüßung | offen | |
280 Tests, alle grün. Was noch fehlt, steht vollständig in 322 Tests, alle grün. Was noch fehlt, steht vollständig in
[`offene-punkte.md`](offene-punkte.md). [`offene-punkte.md`](offene-punkte.md).
## Neu: der Dienst läuft zentral ## Neu: der Dienst läuft zentral
@@ -83,9 +83,27 @@ Durchlauf, im Entwurfsmodus.
## Das Overlay steht ## Das Overlay steht
Fünf Browser-Sources unter `apps/overlay-web/public`, ausgeliefert vom Browser-Sources unter `apps/overlay-web/public`, ausgeliefert vom
Overlay-Server: Einblendungen, Chat, Statusleiste, Effekte, Pausenbild. Kein Overlay-Server: Einblendungen, Chat, Kamerarahmen, Statusleiste, Effekte,
Build, kein Framework - OBS lädt die Dateien direkt. Pausenbild, Vorlesen, Liegestütze. Kein Build, kein Framework - OBS lädt die
Dateien direkt.
**Nächste Sitzung: das Overlay wird neu gemacht.** Gestaltung von Grund auf,
nicht nur der Kamerarahmen. Was bleiben sollte, weil es Fehler verhindert, die
man erst im Stream bemerkt: `client.js` (Neuverbinden, Wachhund,
Schnappschuss, `Overlay.esc`), der durchsichtige Hintergrund und die Kontur
aus `theme.css`, `?probe=1` zum Ansehen im Browser. Die Ereignisse auf dem Bus
(`apps/overlay-server/src/bus.ts`) sind die Schnittstelle, an der sich die
neuen Seiten festhalten können.
**Chat-Abzeichen sind seit 2026-10-27 Twitchs echte Bilder** statt farbiger
Punkte. IRC liefert nur `moderator/1`, `subscriber/3012` usw.; der Hub löst das
über Helix (global und Kanal, Kanal gewinnt) zu Bildadressen auf und hängt sie
an jede Chatzeile (`packages/twitch/src/badges.ts`, Feld `badges` im
`ChatEvent`). Geladen beim Start und alle sechs Stunden, beim Ausrollen
kamen 552. Unbekanntes fällt weg, die Seite nimmt nur Adressen von
`static-cdn.jtvnw.net`. Im echten Stream noch nicht angesehen; zeigt OBS
weiter Punkte, die Browser-Source einmal aktualisieren.
Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was Die Ereignisse kommen über Server-Sent Events statt WebSocket. Warum, und was
gegen die tote Browser-Source nach einem Neustart getan ist, steht in gegen die tote Browser-Source nach einem Neustart getan ist, steht in
@@ -110,6 +128,52 @@ Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten". Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
## Das Pult: Handy als Stream Deck
Seit 2026-10-26 auf dem Hub ausgerollt und im Paket 0.5.7. **Seit 2026-10-27
hängt Abdus Stream-PC dran:** obs-websocket an, Passwort auf der
Einrichtungsseite hinterlegt, der PC (`DESKTOP-DGJ4HC6`) hat sich beim Hub
gemeldet und ist freigegeben, im Hub-Log steht `Stream-PC verbunden`. Ob
Szenenwechsel, Stummschalter, Sounds und das Anlegen der Quelle in OBS
greifen, ist damit noch nicht beobachtet. Auf Abdus Rechner ist nichts auszuführen: das Update kommt über
die tägliche Aufgabe, danach meldet sich der PC von selbst, Abdu tippt im Pult
einmal „Freigeben“, und die Sound-Quelle legt `ingest` selbst in OBS an. `/pult` auf dem Hub ist eine installierbare Web-App (PWA) mit
Soundboard, Szenenwechsel, Clip-Knopf, Stummschaltern, Liegestützen,
Vorlese-Freigabe und den Testknöpfen. Beschreibung in
[`apps/overlay-web/README.md`](../apps/overlay-web/README.md#das-pult-pult).
**Seit 2026-10-27 mit Chat, nur lesend.** Eigener Reiter am Handy; ab
Tablet-Größe (760×500) steht der Chat fest rechts neben dem gewählten
Reiter, also etwa Szenen und Chat auf einem Bildschirm. Reiner Client-Code in
`pult.js`: der Chat kam schon immer über den Ereignisstrom des Pults, es
braucht weder neue Routen noch Rechte. Neu am Bus ist nur `ts` an der
Chatzeile, für die Uhrzeit. Geprüft im Browser mit `overlay --demo` in vier
Größen (Handy hoch und quer, iPad hoch und quer); auf einem echten Tablet
und im Stream noch nicht.
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
dem Hub unter `<db>/sounds/`, gespielt wird über die Browser-Source
`abdulive Sounds`, die `ingest` selbst in OBS anlegt.
- **Szenen** laufen über die Leitung Stream-PC → Hub. Der PC meldet sich
beim ersten Start von selbst, die Freigabe ist ein Tipp im Pult. In OBS
muss nur obs-websocket an sein.
- **Restrisiko der Freigabe:** Anfragen kann jeder, der die Adresse kennt.
Freigeben kann nur, wer im Pult angemeldet ist, und das Pult zeigt Name und
Adresse des Rechners. Wer etwas freigibt, das er nicht erwartet, gibt einem
Fremden Szenen, Stummschalter und Clip-Knopf - nicht mehr.
- Geprüft: 319 Tests, Typprüfung, Durchläufe im Browser gegen einen
nachgebauten Stream-PC mit falschem OBS (Freigabe, Einrichten der
Sound-Quelle, Szenenwechsel, Stummschalten, Hochladen, Abspielen); 8 MB
Upload durch den Proxy. Ungeprüft: echtes OBS (vor allem das Anlegen der
Quelle), iPhone/Android-Installation.
- **Die Adresse in der Anfragekarte** stimmt: bei Abdus echter Anfrage stand
seine öffentliche Adresse da, nicht die Docker-Brücke (`172.18.0.1` kam nur
bei der Probe aus dem eigenen Netz). Sie ist reine Anzeige - die Freigabe
hängt am Schlüssel, eine wechselnde IP stört nicht, eine feste braucht er
nicht.
- Auf dem Hub stand am 2026-10-26 kurz eine Probe-Anfrage „PRUEFUNG-CLAUDE“.
Sie ist nach zehn Minuten von selbst verfallen und wurde nicht freigegeben.
## Kanalpunkte: „Nachricht vorlesen" läuft ## Kanalpunkte: „Nachricht vorlesen" läuft
Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub
@@ -120,15 +184,20 @@ prüft (`apps/hub/src/rewards.ts`), Piper spricht, `/tts` spielt ab.
| Belohnung | „Nachricht vorlesen", 1000 Punkte, Texteingabe Pflicht | | Belohnung | „Nachricht vorlesen", 1000 Punkte, Texteingabe Pflicht |
| Freigabe | keine, wird direkt vorgelesen | | Freigabe | keine, wird direkt vorgelesen |
| Schutz | Wortfilter (29 Einträge), 200 Zeichen, 3 je Person und Stunde, „Sofort still" auf der Übersichtsseite | | Schutz | Wortfilter (29 Einträge), 200 Zeichen, 3 je Person und Stunde, „Sofort still" auf der Übersichtsseite |
| Stimme | Piper, `de_DE-mls-medium`, Sprecher 0 | | Stimme | Piper, `de_DE-thorsten-medium` (mls klang verwaschen) |
**Die Stimme entsteht im Hub, nicht im Browser.** Zuerst lief es über **Die Stimme entsteht im Hub, nicht im Browser.** Zuerst lief es über
`speechSynthesis` in der Browser-Quelle. Das hat in OBS gesprochen, aber `speechSynthesis` in der Browser-Quelle. Das hat in OBS gesprochen, aber
amerikanisch: OBS' Browser sah keine deutsche Stimme und die Seite nahm die amerikanisch: OBS' Browser sah keine deutsche Stimme und die Seite nahm die
erste beliebige. Piper liegt unter `/opt/piper` im LXC (2 Kerne: 200 Zeichen erste beliebige. Piper liegt unter `/opt/piper` im LXC (2 Kerne: 200 Zeichen
in 0,7 s), das Stimmenmodell unter `models/piper/`. Beides überlebt jedes in 0,7 s), das Stimmenmodell unter `models/piper/`. Beides überlebt jedes
Ausrollen; eingerichtet mit `scripts/install-piper.sh`. Proben aller 236 Ausrollen; eingerichtet mit `scripts/install-piper.sh`.
mls-Sprecher lassen sich mit `--speaker N` erzeugen.
**Die erste Stimme war `de_DE-mls-medium`, Sprecher 0**, und klang im Stream
„wie jemand mit Schlaganfall". Die Phoneme stimmten (`piper --debug`), es lag
am Modell: Hörbuch-Aufnahmen von 236 Laien, Sprecher 0 ist ein beliebiger.
Seit 2026-09-24 läuft `de_DE-thorsten-medium`, ein Sprecher, darum kein
`sprecher` mehr in der Konfiguration. Das mls-Modell liegt noch daneben.
**Die Belohnung muss über die API angelegt sein** (`abdulive rewards:anlegen`). **Die Belohnung muss über die API angelegt sein** (`abdulive rewards:anlegen`).
Nur die App, die eine Belohnung angelegt hat, darf deren Einlösungen Nur die App, die eine Belohnung angelegt hat, darf deren Einlösungen
@@ -142,11 +211,51 @@ zusammengezogenen Text gesucht, kürzere nur am Wortanfang, sonst stünde
„nazi" in „kann azubi". Die Liste steht nur in der Hub-Konfiguration, nicht „nazi" in „kann azubi". Die Liste steht nur in der Hub-Konfiguration, nicht
im Repo. im Repo.
**Der Filter hat eine Stunde lang fast alles abgelehnt.** `www` in der Liste
wurde zusammengezogen zu `w` und traf damit jedes Wort, das mit w anfängt
(„wie", „weg"). Die Punkte gingen zurück, im Log stand nur `Wortfilter`.
Behoben am 2026-09-24: was nach dem Zusammenziehen unter 3 Buchstaben hat,
trifft nur noch als ganzes Wort. **Noch offen:** lange Einträge treffen über
Wortgrenzen hinweg, „Spaß tut gut" fliegt wegen `spast` raus, „eine Gerade"
wegen `neger`. Wer eine Ablehnung nachvollziehen will: der Text steht im
Ereignis `points.redeemed` in der Datenbank.
**Der YAML-Leser konnte keine Listen aus Maps.** `- titel: X` zerfiel in **Der YAML-Leser konnte keine Listen aus Maps.** `- titel: X` zerfiel in
Zeichenketten, jede Belohnung war ohne Meldung unwirksam - auch das Beispiel Zeichenketten, jede Belohnung war ohne Meldung unwirksam - auch das Beispiel
in `config.example.yaml`. Behoben und getestet; die bestehenden Dateien in `config.example.yaml`. Behoben und getestet; die bestehenden Dateien
werden nachweislich gleich gelesen wie vorher. werden nachweislich gleich gelesen wie vorher.
## Kanalpunkte: Liegestütze
Seit 2026-10-04 scharf. Die Belohnung „5 Liegestütze" gab es auf Twitch schon
(von Hand angelegt, 61 Einlösungen bis dahin). Sie steht jetzt in der
Hub-Konfiguration mit `aktion: liegestuetze`, `anzahl: 5`, zugeordnet über den
Titel. Wird sie auf Twitch umbenannt, zählt der Hub nicht mehr mit.
| | |
|---|---|
| Zähler | `apps/hub/src/liegestuetze.ts`, Tabelle `zaehler` (Migration 003), übersteht Neustarts |
| Offen | Einlösung +5. Auf der Übersicht −1/−5/−10, Setzen, auf 0 |
| Gemacht | Was mit −1/−5/−10 abgezogen wird. Setzen zählt nicht, das ist Korrektur. Eigenes Feld zum Korrigieren |
| Neuer Stream | Gemacht auf 0, offen bleibt. Nicht beim Neustart des Hubs mitten im Stream, nicht bei einer Lücke bis 5 Minuten |
| Overlay | `/liegestuetze`, 420×200. `?verstecken=1`, `?gemacht=0`, `?titel=...` |
**Ungeprüft im Stream.** Getestet mit Probeserver und Browser. Weder eine
echte Einlösung noch das Nullen beim Streamstart ist seit dem Ausrollen
passiert. Der Zähler startete bei 0 offen, die 61 alten Einlösungen sind nicht
übernommen. In OBS ist die Quelle noch nicht eingetragen.
**Die Belohnung ist nicht über die API angelegt.** Der Hub kann ihre
Einlösungen deshalb nicht als erledigt melden und schreibt bei jeder eine
Warnung ins Log. Für das Zählen egal. Eine ältere Belohnung „10 Liegestütze"
(12 Einlösungen bis 2026-09-24) steht nicht in der Konfiguration.
**Nebenbei behoben: die Vorlese-Freigabe auf der Übersicht war tot.**
`POST /tts` landete bei der Overlay-Seite `/tts` statt bei der Freigabe, auf
dem Hub ohne Schlüssel sogar mit 403. „Vorlesen", „Ablehnen" und „Sofort
still" haben damit nie gewirkt. Overlay-Seiten werden jetzt nur bei GET
ausgeliefert. Gegen eine echte Warteschlange nicht nachgeprüft.
## Der Discord-Server steht ## Der Discord-Server steht
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
@@ -157,12 +266,28 @@ aufgetragen wird er mit `abdulive discord:setup`. Wiederholbar: gefunden wird
nichts, gelöscht wird nie. nichts, gelöscht wird nie.
Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für Das ist Vorarbeit für drei Werkzeuge. Die Webhooks für
[Go-Live](projects/go-live-broadcaster.md), [Recap](projects/stream-recap.md) [Go-Live](projects/go-live-broadcaster.md), Recap (inzwischen gestrichen)
und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher und [Clips](projects/clip-shorts.md) liegen fertig im verschlüsselten Speicher
unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge unter `goLiveWebhook`, `recapWebhook` und `clipsWebhook`. Wer diese Werkzeuge
baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten. baut, muss sich um Discord nicht mehr kümmern, sondern nur noch posten.
Sieben Kanäle, vier Rollen, zwei AutoMod-Regeln. Der Entwurf und seine Zehn Kanäle, vier Rollen, zwei AutoMod-Regeln. Seit 2026-10-17 dabei:
`🙋│begrüßung` für Discords Beitrittsmeldungen (liefen vorher in
`#allgemein`), und die Foren `🎮│spielvorschläge` und
`👀│reaction-einsendungen`. Beide hatte Abdu als Textkanäle angelegt, sie sind
jetzt Foren mit Tags, die nur Mods setzen, und 👍 als Standard-Reaktion.
Discord wandelt keinen Textkanal in ein Forum um: die beiden leeren
Textkanäle wurden einmalig über die API gelöscht, das Setup-Skript selbst
löscht weiterhin nichts.
Im Spielvorschläge-Forum stehen neun Beiträge, einer je Spiel aus Abdus
Liste, vom Bot angelegt (Titel vorher nachgeschlagen, „involkyr" heißt
Invokyr). In `📢│ankündigungen` hat der Bot das Forum angekündigt, ohne Ping.
Der Bot darf dort trotz Schreibsperre für `@everyone` posten.
**Der Hub ist nicht neu ausgerollt.** Das Setup lief aus einer Kopie im
Container, der laufende Prozess hat noch den Stand davor. Für den Server ist
das egal, für den Knopf auch. Beim nächsten Ausrollen kommt es mit. Der Entwurf und seine
Begründung stehen in [`projects/discord-community.md`](projects/discord-community.md); Begründung stehen in [`projects/discord-community.md`](projects/discord-community.md);
die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser die Kurzfassung ist, dass bei 5 bis 30 Zuschauern fünf sichtbare Kanäle besser
sind als zwanzig leere. sind als zwanzig leere.
@@ -203,6 +328,16 @@ nichts Vertrauliches mehr hineinschreiben, auch nicht in die Doku.
(`ZeasyAlex/abdulive`) und `discord` (`ZeasyAlex/abdulive_-discord`). Wer (`ZeasyAlex/abdulive`) und `discord` (`ZeasyAlex/abdulive_-discord`). Wer
pusht, pusht auf beide, sonst laufen sie auseinander. pusht, pusht auf beide, sonst laufen sie auseinander.
**`deploy-hub.sh` schiebt den Arbeitsstand, nicht den Commit.** Liegt
Unfertiges herum, landet es im LXC. Sauber ausrollen aus einem eigenen
Checkout:
```bash
git worktree add --detach ../abdulive-deploy HEAD
../abdulive-deploy/scripts/deploy-hub.sh
git worktree remove ../abdulive-deploy
```
Die Begründungen stehen in [`decisions/`](decisions/), acht ADRs. Wer eine Die Begründungen stehen in [`decisions/`](decisions/), acht ADRs. Wer eine
davon umwerfen will, liest sie vorher. ADR-0004 (Docker) ist durch ADR-0007 davon umwerfen will, liest sie vorher. ADR-0004 (Docker) ist durch ADR-0007
ersetzt; die Analyse darin bleibt lesenswert, weil zwei der vier ersetzt; die Analyse darin bleibt lesenswert, weil zwei der vier
@@ -245,15 +380,22 @@ npx tsc --noEmit
## Ausliefern ## Ausliefern
Zwei Wege, je nachdem was sich ändert: Hub-Teil (Overlay, Pult, Discord, Chat)
ist mit `deploy-hub.sh` sofort live, Abdu muss nichts tun. Der PC-Teil
(`ingest`) kommt nur mit einem neuen Paket an, also nach einem Versions-Tag;
ein Push auf `main` allein erreicht seinen Rechner nicht.
`build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern, `build-package.ts` lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern,
packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt packt alles nach `dist/` und schreibt `abdulive.json`. Dieses Manifest fragt
`abdulive update` auf Abdus Rechner ab. `abdulive update` auf Abdus Rechner ab.
Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau Die Veröffentlichung `latest` trägt beide Dateien und wird bei jedem Bau
ersetzt, damit die Adresse gleich bleibt. ersetzt, damit die Adresse gleich bleibt.
`.gitea/workflows/release.yml` macht das beim Tag automatisch. **Die Pipeline `.gitea/workflows/release.yml` macht das beim Tag automatisch. **Seit
ist noch nie gelaufen**, bisher wurde von Hand veröffentlicht. Der Runner 2026-10-26 läuft sie** (0.5.6, danach 0.5.7). Der erste Lauf (`v0.5.5`) scheiterte daran,
(`ubuntu-latest`) steht. dass whisper.cpp seine Windows-Programme nicht mehr an `latest` hängt, sondern
an eigene `b…`-Veröffentlichungen; `githubAsset()` sucht jetzt die neueste
mit passendem Archiv. Unter `v0.5.5` liegt kein Paket.
## Windows-Fallen, die schon zugeschlagen haben ## Windows-Fallen, die schon zugeschlagen haben
@@ -348,7 +490,7 @@ Umlaute und das Trennzeichen `│` (U+2502) bleiben unverändert stehen.
dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von dieselbe Umbenennung erneut raus - und läuft nach zwei Läufen in das Limit von
oben. oben.
## Die drei Dinge, die Abdu tun muss ## Was Abdu tun muss
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest 1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
@@ -373,6 +515,9 @@ oben.
Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren. Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren.
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
`abdulive calibrate` zur Kontrolle. `abdulive calibrate` zur Kontrolle.
4. ~~**Stream-PC im Pult freigeben.**~~ Erledigt am 2026-10-27, ebenso
obs-websocket und das Passwort aus Punkt 2. Offen aus Punkt 2 bleiben
Wiedergabepuffer, Hotkey und Audiospuren - nicht nachgeprüft.
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
Chat anonym mit und kann nichts senden. Chat anonym mit und kann nichts senden.
@@ -382,7 +527,7 @@ Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
(`/mod <botname>`). Ohne das gilt ein Limit von 20 Chat-Nachrichten je 30 (`/mod <botname>`). Ohne das gilt ein Limit von 20 Chat-Nachrichten je 30
Sekunden statt 100, und im Follower-only-Modus kommt der Bot gar nicht durch. Sekunden statt 100, und im Follower-only-Modus kommt der Bot gar nicht durch.
Und zwei für Discord, beide nicht über die API machbar: Und drei für Discord, die nicht über die API gehen:
1. **Sich selbst die Rolle `Abdu` geben.** Das Skript legt Rollen an, aber es 1. **Sich selbst die Rolle `Abdu` geben.** Das Skript legt Rollen an, aber es
hängt sie niemandem um. hängt sie niemandem um.
@@ -390,6 +535,9 @@ Und zwei für Discord, beide nicht über die API machbar:
Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream Kanalinfo und in einen Chat-Befehl eintragen. Ein Server, den im Stream
niemand erwähnt, bleibt leer. Das ist der eine Handgriff, an dem hängt, ob niemand erwähnt, bleibt leer. Das ist der eine Handgriff, an dem hängt, ob
der Server überhaupt jemanden bekommt. der Server überhaupt jemanden bekommt.
3. **Tags in den Foren pflegen.** „Wird gespielt", „Gespielt", „Eher nicht"
bzw. „Kommt dran", „Angeschaut" setzen nur Abdu und Mods. Ein Vorschlag,
der sichtbar umgesetzt wird, bringt den nächsten.
## Wo ich weitermachen würde ## Wo ich weitermachen würde
@@ -398,17 +546,30 @@ in der ganzen Kette, und alles Weitere hängt daran. Seit es `abdulive sources`
gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild gibt, ist auch die Frage beantwortbar, welche OBS-Quelle fremden Text ins Bild
malt. Das war vorher nicht zu erraten. malt. Das war vorher nicht zu erraten.
**Das Pult an Abdus OBS abnehmen.** Die Freigabe ist erteilt. Jetzt nachsehen, ob die
Szene `abdulive Ton` mit der Quelle `abdulive Sounds` angelegt und in jede
Szene eingehängt ist, ob ein Sound im Stream zu hören ist und ob Szenenwechsel
und Stummschalter greifen. Das fällt mit der Abnahme von S2 zusammen, beides
braucht nur ein laufendes OBS.
**Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal **Den Go-Live-Ping scharf schalten.** Er steht auf `draft`. Einmal
`abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann `abdulive golive:test --echt` laufen lassen, prüfen ob das Handy klingelt, dann
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten `mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
Wirkung. Wirkung.
**Den Kamerarahmen fertig machen** und die Statusleiste damit ablösen. **Das Overlay neu machen** (nächste Sitzung, siehe oben). Der Kamerarahmen
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts` **Recap und die übrigen geplanten Werkzeuge sind gestrichen** (2026-10-27).
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedächtnis, Punkteshop und
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt Recap sind samt Platzhaltern, Spezifikationen und Konfigurationsabschnitten
bereit, der Hub hat die Daten. entfernt. Der Hub schreibt keine Chatnachrichten und Zuschauerproben mehr in
die Datenbank, gelesen haben die nur diese Werkzeuge; die Tabellen bleiben
stehen. Der angefangene Recap liegt nur im Git-Stash
`recap WIP (vor dem Rueckbau 2026-10-27)` des Rechners, auf dem er
angefangen wurde. Der Webhook `recapWebhook` steht weiter im Discord-Bauplan,
weil das Setup-Skript nie löscht.
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne **Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
@@ -428,7 +589,7 @@ hörbar („Danke" wurde „Dank es"), DejaVu Sans ist unauffällig, und das
fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs fortlaufende Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs
konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md). konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md).
**Die CI ist geschrieben, aber ungetestet.** Beim ersten Tag zusehen. **Die CI läuft, aber nur einmal erfolgreich.** Beim nächsten Tag kurz hinsehen.
**Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation. **Das Paket ist nicht signiert.** SmartScreen warnt bei jeder Installation.
Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es Solange nur Abdu installiert, reicht ein Klick. Sobald jemand Drittes es
@@ -442,7 +603,7 @@ auf denselben Klick. Auf dem Stream-PC bleibt das Discord-Token leer.
**Der Overlay-Schlüssel steht in der Adresse.** Damit steht er in jedem **Der Overlay-Schlüssel steht in der Adresse.** Damit steht er in jedem
OBS-Profil und auf jedem Screenshot der Einstellungen. Neu würfeln geht auf OBS-Profil und auf jedem Screenshot der Einstellungen. Neu würfeln geht auf
der Übersichtsseite, aber danach müssen alle fünf Adressen in OBS neu der Übersichtsseite, aber danach müssen alle Adressen in OBS neu
eingetragen werden. eingetragen werden.
**Das Overlay hat nie unter Last gelaufen.** Geprüft ist es gegen den **Das Overlay hat nie unter Last gelaufen.** Geprüft ist es gegen den
+1 -7
View File
@@ -6,13 +6,7 @@ Entscheidungen, Fallstricke, Abnahmekriterien.
| # | Dokument | App | | # | Dokument | App |
|---|---|---| |---|---|---|
| — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` | | — | [Ingest-Daemon](00-ingest.md) | `apps/ingest` |
| 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/clipper` | | 1 | [Clip-to-Shorts](clip-shorts.md) | `apps/ingest` |
| 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` | | 2 | [Go-Live-Broadcaster](go-live-broadcaster.md) | `apps/broadcaster` |
| 3 | [Raid- und Netzwerk-Tool](raid-network.md) | `apps/raid-scout` |
| 4 | [Analytics-Dashboard](analytics.md) | `apps/dashboard` |
| 5 | [VOD-Kapitel](vod-chapters.md) | `apps/vod-chapters` |
| 6 | [Community-Gedaechtnis](community-memory.md) | `apps/community` |
| 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` | | 7 | [Chat steuert Dinge](chat-control.md) | `apps/overlay-server`, `apps/overlay-web` |
| 8 | [Punktesystem und Shop](loyalty-shop.md) | `apps/loyalty` |
| 9 | [Stream-Recap](stream-recap.md) | `apps/recap` |
| — | [Discord-Community](discord-community.md) | `packages/discord` | | — | [Discord-Community](discord-community.md) | `packages/discord` |
-68
View File
@@ -1,68 +0,0 @@
# 4 — Analytics-Dashboard (`apps/dashboard`)
**Wirkrichtung:** Steuerung. Beantwortet die Frage "was funktioniert
eigentlich", die Twitch selbst nicht beantwortet.
Läuft auf `http://localhost:4792`. Vite + Preact + uPlot, liest direkt aus
SQLite. Keine Authentifizierung, weil nur lokal erreichbar.
## Seiten
### Stream-Zeitachse (die wichtigste)
Eine gemeinsame X-Achse (Stream-Laufzeit), darauf übereinander:
- Viewerzahl (Linie, aus `viewer_samples`)
- Chat-Nachrichten pro Minute (Balken)
- unterschiedliche Chatter pro 5 min (Linie)
- Marker: Kategoriewechsel, Titelwechsel, Raids rein/raus, Clips, Follows, Subs
Damit sieht man direkt: An welcher Stelle kamen Leute rein, an welcher gingen
sie, und war der Chat da tot oder wach. **Zusammenhang von Viewerzahl und
Chat-Aktivität ist die Kernfrage** — 15 Zuschauer mit stillem Chat sind
weniger wert als 8 mit lebendigem.
### Abgangs-Analyse
Aus `viewer_samples`: Ableitung über die Zeit, die stärksten Abfälle markiert
und mit dem gleichzeitigen `stream_segments`-Eintrag und den Chat-Nachrichten
der Minute davor angereichert. Antwort auf "wann gehen die Leute".
Wichtige Einschränkung, die im UI stehen muss: `viewer_samples` ist eine
Momentaufnahme der Gesamtzahl, keine Ein-/Austrittsliste. Twitch liefert das
nicht. Ein Abfall von 12 auf 8 kann vier Abgänge oder sechs Abgänge plus zwei
Zugänge bedeuten. Für Chatter ist es exakt (über `user_streams`), für stille
Zuschauer geschätzt.
### Titel- und Kategorie-Performance
Aggregation über `stream_segments`: pro Titel/Kategorie die durchschnittliche
Viewerzahl, der Nettozufluss während des Segments, Chat-Rate, gewonnene
Follower pro Stunde. Sortierbar. Mit Hinweis auf die Stichprobengröße —
bei drei Streams pro Titel ist jede Zahl Rauschen, und das Dashboard sagt das,
statt eine Rangliste vorzutäuschen.
### Chatter: neu vs. wiederkehrend
Pro Stream ein Stapelbalken: Erstchatter / wiederkehrend (1–4 Streams) /
Stammgast (5+). Dazu eine Kohorten-Tabelle: Von den Erstchattern aus Stream N,
wie viele waren in N+1, N+2, N+3 wieder da. Das ist die Zahl, die tatsächlich
über Wachstum entscheidet.
### Betriebsstatus
`GET /health` vom Ingest, letzte Fehler aus `logs/`, Größe der DB,
Clip-Warteschlange.
## Clip-Freigabe
Zusätzlich hostet das Dashboard die Freigabe-Ansicht für die Clip-Pipeline
(siehe [`clip-shorts.md`](clip-shorts.md)): Kandidatenliste mit
Videovorschau, Score, Chat-Ausschnitt und den Knöpfen *Übernehmen* /
*Verwerfen*.
## Abnahmekriterien
- Die Zeitachse eines abgeschlossenen Streams lädt in unter 2 s.
- Alle Marker liegen zeitlich korrekt (Abgleich gegen das VOD).
- Die Kohorten-Tabelle stimmt mit einer manuellen SQL-Auszählung überein.
-76
View File
@@ -1,76 +0,0 @@
# 6 — Community-Gedächtnis (`apps/community`)
**Wirkrichtung:** Bindung. Bei 10 Zuschauern der Unterschied zwischen
"Zuschauer" und "Community".
Läuft als Modul im `overlay-server`, nicht als eigener Prozess — es braucht
dieselbe Chat-Verbindung und denselben WS-Kanal zum Overlay.
## Datenlage
Alles Nötige liegt bereits in `users` und `user_streams` (siehe
[`../03-data-model.md`](../03-data-model.md)). Dieses Tool ist überwiegend
**Auswertung und Ausgabe**, kaum neue Datenerfassung — deshalb ist es billig,
sobald der Ingest steht.
## Die drei Ausgaben
### 1. Chat-Begrüßung
Ausgelöst durch die erste Nachricht eines Users im laufenden Stream:
| Fall | Bedingung | Beispiel |
|---|---|---|
| **Erstchatter** | `first-msg`-Tag oder `users.message_count = 0` | "Oh, @name schreibt zum ersten Mal — willkommen!" |
| **Rückkehrer** | letzter Besuch > 21 Tage her | "@name, lange nicht gesehen." |
| **Meilenstein** | `streams_chatted` in {5, 10, 25, 50, 100} | "Stream Nummer 25 für @name." |
| **Stammgast** | `streams_chatted >= 5` | individuelle Begrüßung aus `users.greeting` |
| sonst | — | nichts |
Regeln, damit es nicht nervt: höchstens eine Begrüßung alle 20 s, höchstens
zehn pro Stream, `muted_greeting` respektiert, und **keine Begrüßung, wenn Abdu
in den letzten 15 s selbst gesprochen hat** (der Ingest weiß über OBS bzw. über
eine Audiopegel-Schwelle, ob gerade geredet wird) — sonst redet der Bot dem
Streamer ins Wort.
### 2. Overlay-Hinweis für Abdu
Kleines, nur für Abdu sichtbares Panel (eigene Browser-Source in der
Studio-Ansicht oder zweiter Monitor). Wenn jemand schreibt:
```
┌──────────────────────────────────┐
│ maxi_2k Stream 15 · Sub 3M │
│ zuletzt: vor 2 Tagen │
│ Notiz: studiert Maschinenbau, │
│ spielt selbst Elden Ring │
│ erste Nachricht: 04.03.2026 │
└──────────────────────────────────┘
```
**Das ist der eigentliche Wert.** Nicht der Bot, der etwas schreibt, sondern
Abdu, der weiß, mit wem er redet. Ein Bot, der "willkommen zurück" sagt, ist
Deko; ein Streamer, der "na, wie war die Klausur" sagt, bindet Menschen.
### 3. Notizen
Chat-Command für Abdu und Mods: `!notiz <name> <text>` hängt an `users.notes` an.
Im Dashboard editierbar. Das ist die einzige Datenquelle, die Handarbeit
braucht, und die mit dem höchsten Nutzen pro Aufwand.
## Datenschutz
Die DB enthält personenbezogene Daten (Chatverläufe, Notizen). Sie liegt lokal,
wird nicht geteilt und nicht in ein Repo committet (`data/` ist in `.gitignore`).
`!vergissmich` löscht auf Wunsch alle Daten eines Users bis auf die anonyme
Aggregatstatistik. Notizen enthalten nichts, was man der Person nicht ins
Gesicht sagen würde.
## Abnahmekriterien
- Ein Testkonto, das zum ersten Mal schreibt, wird als Erstchatter erkannt und
genau einmal begrüßt.
- Nach einem Namenswechsel des Testkontos bleibt die Historie erhalten
(Verknüpfung über `user_id`, nicht `login`).
- Das Overlay-Panel zeigt den Hinweis innerhalb von 500 ms nach der Nachricht.
- Zwanzig Nachrichten in fünf Sekunden erzeugen höchstens eine Begrüßung.
+21 -1
View File
@@ -5,7 +5,7 @@ ist der Ort, an dem sie zwischen den Streams noch da sind. Ohne den zweiten Ort
faengt jeder Stream bei null an. faengt jeder Stream bei null an.
Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin: Der Server ist kein Tool aus der Neunerliste, aber drei davon posten dorthin:
[Go-Live-Broadcaster](go-live-broadcaster.md), [Stream-Recap](stream-recap.md) [Go-Live-Broadcaster](go-live-broadcaster.md), Stream-Recap (gestrichen)
und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo und [Clip-to-Shorts](clip-shorts.md). Deshalb steht die Struktur hier im Repo
und nicht nur in einer Oberflaeche. und nicht nur in einer Oberflaeche.
@@ -33,9 +33,12 @@ und ist die massgebliche Fassung. Was hier steht, ist die Begruendung dazu.
| Kategorie | Kanal | Wer schreibt | Wofuer | | Kategorie | Kanal | Wer schreibt | Wofuer |
|---|---|---|---| |---|---|---|---|
| 📌 Info | `👋│willkommen` | niemand | Die drei Regeln, einmal gepostet | | 📌 Info | `👋│willkommen` | niemand | Die drei Regeln, einmal gepostet |
| 📌 Info | `🙋│begrüßung` | alle | Discords Beitrittsmeldungen |
| 📌 Info | `📢│ankündigungen` | nur Webhooks | Stream-Start und Recap; Diskussion in Threads | | 📌 Info | `📢│ankündigungen` | nur Webhooks | Stream-Start und Recap; Diskussion in Threads |
| 🎮 Community | `💬│allgemein` | alle | Der Hauptkanal. Im Zweifel hier | | 🎮 Community | `💬│allgemein` | alle | Der Hauptkanal. Im Zweifel hier |
| 🎮 Community | `🎬│clips` | alle + Clip-Knopf | Gute Momente | | 🎮 Community | `🎬│clips` | alle + Clip-Knopf | Gute Momente |
| 🎮 Community | `🎮│spielvorschläge` | alle (Forum) | Ein Beitrag je Spiel, abgestimmt per 👍 |
| 🎮 Community | `👀│reaction-einsendungen` | alle (Forum) | Ein Beitrag je Clip, auf den Abdu reagieren soll |
| 🎮 Community | `🔊 Zusammen zocken` | — | Sprachkanal | | 🎮 Community | `🔊 Zusammen zocken` | — | Sprachkanal |
| 🔒 Intern | `🔨│mod-chat` | Mods | Fuer alle anderen unsichtbar | | 🔒 Intern | `🔨│mod-chat` | Mods | Fuer alle anderen unsichtbar |
| 🔒 Intern | `🤖│technik-log` | Toolkit | Probelaeufe, Fehler, AutoMod-Treffer | | 🔒 Intern | `🤖│technik-log` | Toolkit | Probelaeufe, Fehler, AutoMod-Treffer |
@@ -54,6 +57,23 @@ Server ist der erste Eindruck, den jemand davon bekommt.
So bleibt die Kanalansicht eine saubere Liste von Streams, und die Diskussion So bleibt die Kanalansicht eine saubere Liste von Streams, und die Diskussion
ueber das Recap haengt trotzdem direkt daran. ueber das Recap haengt trotzdem direkt daran.
`🙋│begrüßung` gibt es, weil Discord die Beitrittsmeldungen sonst in den
ersten Kanal des Servers schreibt, und das war `💬│allgemein`. Dort gingen die
Meldungen im Gespräch unter und das Gespräch in den Meldungen. Der Kanal ist
beschreibbar, sonst tut der Winke-Knopf unter der Meldung nichts. Welcher
Kanal es ist, steht in `SYSTEM_CHANNEL`.
Die beiden Foren hat Abdu als Textkanäle angelegt, seit 2026-10-17 stehen sie
als Foren im Bauplan. In einem Textkanal schlägt nach drei Wochen jemand
dasselbe Spiel noch einmal vor, weil der erste Vorschlag weggescrollt ist. Im
Forum gibt es einen Beitrag je Spiel, darunter wird per 👍 abgestimmt. Die
Tags für den Stand („Wird gespielt", „Gespielt", „Eher nicht" bzw. „Kommt
dran", „Angeschaut") setzen nur Mods. Sonst markiert jeder sein eigenes Spiel
als „wird gespielt". Discord wandelt einen Textkanal nicht in ein Forum um:
die leeren Textkanäle wurden einmalig von Hand gelöscht, das Skript selbst
löscht weiterhin nichts. Foren gehen auch ohne Community-Server, am echten
Server nachgeprüft.
Der Sprachkanal ist bewusst dabei, obwohl er anfangs leer sein wird. Ein Der Sprachkanal ist bewusst dabei, obwohl er anfangs leer sein wird. Ein
Sprachkanal, den zwei Leute nutzen, ist der Anfang; einer, den es nicht gibt, Sprachkanal, den zwei Leute nutzen, ist der Anfang; einer, den es nicht gibt,
wird nie einer. wird nie einer.
-71
View File
@@ -1,71 +0,0 @@
# 8 — Punktesystem mit Bedeutung (`apps/loyalty`)
**Wirkrichtung:** Bindung. Läuft als Modul im `overlay-server`.
## Warum nicht Channel Points?
Twitch-Channel-Points funktionieren, haben aber zwei Grenzen: Man kann die
Vergabelogik nicht anpassen (keine Boni für Verhalten, das man belohnen will),
und Einlösungen sind an das Twitch-UI gebunden. Eigene Punkte laufen daneben.
**Beides parallel betreiben.** Channel Points bleiben für simple Sachen,
eigene Punkte für alles, was Logik braucht. `channel.channel_points_custom_reward_redemption.add`
wird ohnehin schon ingested, also können beide Systeme dieselben Aktionen
auslösen.
## Vergabe
| Anlass | Punkte |
|---|---|
| pro 10 Minuten Anwesenheit | 10 |
| ...davon aktiv im Chat | +5 |
| erste Nachricht des Streams | 25 |
| Anwesenheit in Streamminute 1–10 | +50 (belohnt pünktliches Erscheinen) |
| Follow | 200 (einmalig) |
| Sub / Resub | 500 |
| Raid rein | 300 an den Raider |
| Clip, der übernommen wird | 250 an den Auslöser |
Anwesenheit kommt aus `user_streams.minutes` (Helix-Chatter-Liste alle 5 min),
nicht aus dem Chat allein — sonst bekommen stille Zuschauer nie etwas.
## Shop
Der Punkt der Sache ist, dass Einlösungen **wirklich etwas tun**:
| Artikel | Kosten | Aktion | Freigabe |
|---|---|---|---|
| Streamtitel für 10 min | 5.000 | `PATCH /helix/channels`, Timer setzt zurück | ja |
| Emote-Regen 30 s | 800 | Overlay-Effekt | nein |
| Sound aus dem Board | 300 | Overlay + Audio | nein |
| Challenge für Abdu | 3.000 | Karte im Overlay, Abdu bestätigt | ja |
| Nächstes Spiel bestimmen | 10.000 | Eintrag in die Warteschlange | ja |
| Facecam-Filter 60 s | 1.500 | obs-websocket Filter an | nein |
`requires_approval` ist bei allem gesetzt, was den Stream nach außen verändert
(Titel!) oder Abdu zu etwas verpflichtet. Titeländerung zusätzlich abgesichert:
Wortfilter, Längenbegrenzung, automatische Rückkehr zum Originaltitel per
Timer, und der Originaltitel wird vorher in `stream_segments` gesichert.
## Rückerstattung
Wenn eine Aktion fehlschlägt (OBS nicht erreichbar, Helix-Fehler, Abdu lehnt
ab), werden die Punkte automatisch zurückgebucht und der User im Chat
informiert. `redemptions.status = 'refunded'`.
## Bilanz-Integrität
Punkte werden nie direkt auf `users.points` geschrieben, sondern als Zeilen in
einer `points_ledger`-Tabelle geführt; `users.points` ist ein
Materialized-Summenfeld, das über einen Trigger aktualisiert wird. So ist jede
Punktzahl nachvollziehbar und ein Fehler in der Vergabelogik reparierbar, ohne
dass jemandem grundlos Punkte fehlen.
## Abnahmekriterien
- Ein Zuschauer, der 30 min anwesend ist und dreimal schreibt, hat exakt die
erwartete Punktzahl.
- Titel-Einlösung ändert den Titel und setzt ihn nach 10 min zurück, auch wenn
der Server dazwischen neu startet (Timer wird persistiert).
- Fehlgeschlagene Einlösung bucht zurück.
- `points_ledger`-Summe stimmt mit `users.points` überein.
-64
View File
@@ -1,64 +0,0 @@
# 3 — Raid- und Netzwerk-Tool (`apps/raid-scout`)
**Wirkrichtung:** Reichweite über Menschen statt über Algorithmen. In dieser
Kanalgröße die zuverlässigste Quelle für neue Stammzuschauer.
## Kandidaten finden
`GET /helix/streams?game_id=<aktuelle Kategorie>&first=100&language=de`,
seitenweise bis die Viewerzahl unter die Untergrenze fällt (Twitch sortiert
absteigend, also von hinten interessant). Filter:
- Viewer zwischen `min` und `max` (Default 5–30, konfigurierbar)
- Sprache `de`
- Uptime mindestens 30 min und höchstens 5 h — wer gerade erst angefangen hat,
verliert durch einen Raid nichts, wer gleich aufhört, kann nichts damit
anfangen
- nicht auf der Sperrliste (`config/raid-blocklist.yaml`)
## Bewertung
```
score = 0.35 * viewerNaehe (Ziel: aehnliche Groesse wie wir)
+ 0.25 * kategorieUeberlapp (gleiche Kategorie > gleiche Sparte)
+ 0.20 * beziehung (schon mal Kontakt gehabt? zurueckgeraidet?)
+ 0.10 * frische (lange nicht mehr geraidet)
+ 0.10 * uptimeFenster
```
`beziehung` kommt aus der `raids`-Tabelle: Wer uns geraidet hat und noch keinen
Raid zurückbekommen hat, steht ganz oben. **Das ist der eigentliche Kern des
Tools** — nicht das Finden von Fremden, sondern das Nicht-Vergessen von
Leuten, die schon Kontakt aufgenommen haben.
## Oberfläche
- **Chat-Command `!raidziel`** (nur Mods/Broadcaster): Bot postet die Top 3 mit
Login, Viewerzahl und Kategorie in den Chat.
- **Overlay-Panel**, das Abdu am Streamende einblenden kann: Top 5 als Karten,
Klick kopiert `/raid <login>` in die Zwischenablage. Bewusst **kein
automatisches Auslösen** — ein Raid ist eine soziale Geste, die einen
Menschen braucht.
- **Dashboard-Seite** mit der vollständigen Raid-Historie.
## Netzwerk-Gedächtnis
Für jeden Kanal, mit dem es je Kontakt gab:
| Feld | Quelle |
|---|---|
| Raids rein / raus, mit Datum und Viewerzahl | `channel.raid` EventSub |
| erwidert ja/nein | berechnet |
| Notizen | manuell im Dashboard |
| letzter Kontakt | berechnet |
Wöchentliche Erinnerung im Dashboard: "Diese 3 haben dich geraidet, du sie noch
nicht."
## Abnahmekriterien
- `!raidziel` liefert im laufenden Stream innerhalb von 3 s drei plausible
Kanäle in der aktuellen Kategorie.
- Ein eingehender Raid landet automatisch in `raids` mit `direction='in'`.
- Ein ausgehender Raid (manuell in Twitch ausgelöst) wird ebenfalls erfasst.
- Kanäle auf der Sperrliste tauchen nie auf.
-67
View File
@@ -1,67 +0,0 @@
# 9 — Stream-Recap (`apps/recap`)
**Wirkrichtung:** Bindung. Gibt Leuten einen Grund, im Discord zu sein, und
hält den Kanal zwischen zwei Streams präsent.
## Ablauf
`stream.offline` → 10 Minuten warten (VOD-Verarbeitung, letzte Clips rendern)
→ Zahlen aus der DB ziehen → Discord-Post → optional Bluesky-Kurzfassung.
## Inhalt
```
🔴 Stream vom 09.09. · 3 h 42 min · Elden Ring, Just Chatting
Peak 17 Zuschauer · Schnitt 11
23 Leute im Chat, davon 4 zum ersten Mal
+6 Follower
Top-Chatter
1. maxi_2k 84 Nachrichten
2. lenaaa 61
3. der_olli 47
Willkommen im Chat 👋
@neuer1 @neuer2 @neuer3 @neuer4
Beste Momente
▸ "Malenia Versuch 41" clips.twitch.tv/...
▸ "Der Hund im Hintergrund" clips.twitch.tv/...
VOD-Kapitel
00:00 Start
00:14 Elden Ring
01:32 Pause
...
Nächster Stream: Mittwoch 20 Uhr
```
## Regeln, damit es gelesen wird
- **Namen vor Zahlen.** Der Teil, der Leute in den Discord bringt, ist die
Liste mit ihren Namen darin — nicht die Peak-Viewer-Zahl. Erstchatter werden
namentlich begrüßt, Top-Chatter genannt.
- **Keine schlechten Streams schönreden, aber auch nicht ausstellen.** Wenn Peak
unter der Hälfte des Schnitts der letzten fünf Streams liegt, entfallen die
Viewer-Zahlen und der Post konzentriert sich auf Chat und Momente. Öffentlich
schwache Zahlen zu posten schadet mehr, als es nützt; im Dashboard stehen sie
trotzdem.
- **Kein Post ohne Inhalt.** Streams unter 20 Minuten oder ohne Chat-Aktivität
erzeugen keinen Recap.
- **Clips einbetten, nicht verlinken.** Discord bettet Twitch-Clips ab; die
besten zwei nach `clips.score`.
## Vergleich zur Vorwoche
Zweite Zeile, klein: "Chat +18 % ggü. letzten 4 Streams". Nur bei mindestens
vier Vergleichsstreams, sonst weggelassen — Prozentangaben auf Basis von zwei
Datenpunkten sind Unsinn.
## Abnahmekriterien
- Der Post erscheint innerhalb von 15 min nach Streamende.
- Alle Zahlen stimmen mit dem Dashboard überein.
- Ein Stream ohne Chat erzeugt keinen Post.
- Clip-Einbettungen funktionieren in Discord (nicht nur als nackte URL).
-54
View File
@@ -1,54 +0,0 @@
# 5 — VOD-Kapitel (`apps/vod-chapters`)
**Wirkrichtung:** Steuerung und ein wenig Reichweite (YouTube-Zweitverwertung).
## Was aufgezeichnet wird
Während des Streams sammelt der Ingest ohnehin schon:
| Signal | Quelle | wird Kapitel? |
|---|---|---|
| Kategoriewechsel | EventSub `channel.update` | immer |
| Titelwechsel | EventSub `channel.update` | wenn Kategorie gleich bleibt |
| OBS-Szenenwechsel | obs-websocket | nur bei Szenen aus der Whitelist |
| Stream-Marker | `POST /helix/streams/markers` | immer |
| erstellter Clip | `clips` | als Kapitel-Kandidat |
OBS-Szenen sind das rauschendste Signal — ein Wechsel auf "BRB" und zurück sind
zwei Wechsel, die kein Kapitel sind. Deshalb Whitelist in `config/obs-scenes.yaml`
plus Mindestabstand von 5 Minuten zwischen Kapiteln; kürzere Segmente werden mit
dem vorherigen verschmolzen.
## Ausgabe
Nach `stream.offline`: VOD über `GET /helix/videos?user_id=&type=archive&first=1`
holen, Zeitstempel relativ zu `streams.started_at` umrechnen, Text erzeugen:
```
00:00 Start & Begrüßung
00:14 Elden Ring — Malenia, Versuch 40
01:32 Pause
01:47 Elden Ring — sie ist tot
02:55 Just Chatting — Community-Fragen
```
Format wählbar: Twitch-VOD-Beschreibung, YouTube-Kapitel (identisch, muss aber
bei `00:00` beginnen und mindestens drei Einträge mit je mindestens 10 s haben,
sonst zeigt YouTube keine Kapitel an), Discord-Nachricht.
Twitch erlaubt kein Setzen der VOD-Beschreibung über die API — der Text landet
deshalb in `data/vods/<datum>/chapters.txt` und wird per Push-Nachricht
zugestellt, plus als Block im Recap-Post. Einfügen dauert zehn Sekunden.
## Kapitel-Titel
Der Rohtitel ist der Twitch-Titel bzw. der Kategoriename. Optional (Ausbaustufe 2)
eine Verdichtung aus dem Chat: die häufigsten Substantive im Segment als
Zusatz. Erst sinnvoll, wenn genug Chat-Volumen da ist.
## Abnahmekriterien
- Ein Testtream mit zwei Kategoriewechseln und drei Szenenwechseln erzeugt
genau die erwarteten Kapitel.
- Zeitstempel weichen vom VOD um weniger als 5 s ab.
- Kein Kapitel unter 5 Minuten Länge.
+18 -5
View File
@@ -60,8 +60,8 @@ export type Config = {
enabled: boolean; enabled: boolean;
belohnungen: Array<{ belohnungen: Array<{
titel?: string; id?: string; titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
freigabe?: boolean; maxZeichen?: number; freigabe?: boolean; maxZeichen?: number; anzahl?: number;
}>; }>;
tts: { tts: {
freigabe: boolean; maxZeichen: number; freigabe: boolean; maxZeichen: number;
@@ -80,6 +80,12 @@ export type Config = {
* Redirect-URI passen muss. * Redirect-URI passen muss.
*/ */
publicUrl: string; publicUrl: string;
/**
* Wo der Hub steht, bei dem sich der Stream-PC fuers Pult anmeldet. Als
* Vorgabe fest eingetragen, damit auf Abdus Rechner nichts einzurichten
* ist. Leer heisst: keine Fernsteuerung (auch per FERN_URL= abschaltbar).
*/
fern: { url: string };
ingestServer: { port: number }; ingestServer: { port: number };
dashboard: { port: number }; dashboard: { port: number };
raw: YamlMap; raw: YamlMap;
@@ -135,8 +141,8 @@ const DEFAULTS = {
enabled: false, enabled: false,
belohnungen: [] as Array<{ belohnungen: [] as Array<{
titel?: string; id?: string; titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
freigabe?: boolean; maxZeichen?: number; freigabe?: boolean; maxZeichen?: number; anzahl?: number;
}>, }>,
tts: { tts: {
// Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim // Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim
@@ -146,11 +152,12 @@ const DEFAULTS = {
maxZeichen: 200, maxZeichen: 200,
blockliste: [] as string[], blockliste: [] as string[],
proPersonProStunde: 3, proPersonProStunde: 3,
stimme: 'de_DE-mls-medium', stimme: 'de_DE-thorsten-medium',
}, },
}, },
overlay: { port: 4791, enabled: true }, overlay: { port: 4791, enabled: true },
publicUrl: '', publicUrl: '',
fern: { url: 'https://abdu.dimension47.de' },
ingestServer: { port: 4790 }, ingestServer: { port: 4790 },
dashboard: { port: 4792 }, dashboard: { port: 4792 },
}; };
@@ -180,6 +187,12 @@ export function loadConfig(): Config {
// Unit-Datei, nicht in einer YAML, die mitversioniert wird. // Unit-Datei, nicht in einer YAML, die mitversioniert wird.
if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL; if (process.env.PUBLIC_URL) merged.publicUrl = process.env.PUBLIC_URL;
merged.publicUrl = merged.publicUrl.replace(/\/+$/, ''); merged.publicUrl = merged.publicUrl.replace(/\/+$/, '');
// Auch leer gesetzt gewinnt die Umgebung: so meldet sich ein Rechner zum
// Entwickeln nicht beim echten Hub an.
// Neues Objekt statt Zuweisung: ohne Eintrag in der YAML ist merged.fern
// dasselbe Objekt wie in DEFAULTS.
const fernUrl = process.env.FERN_URL ?? merged.fern.url;
merged.fern = { ...merged.fern, url: fernUrl.replace(/\/+$/, '') };
cached = { ...merged, raw } as Config; cached = { ...merged, raw } as Config;
return cached; return cached;
} }
+8
View File
@@ -0,0 +1,8 @@
-- Zaehler, die einen Neustart ueberleben muessen. Erster Fall: offene
-- Liegestuetze aus Kanalpunkten. Wer 2000 Punkte ausgegeben hat, will nicht
-- erleben, dass ein Update des Hubs seine Liegestuetze verschluckt.
CREATE TABLE zaehler (
name TEXT PRIMARY KEY,
wert INTEGER NOT NULL,
geaendert TEXT NOT NULL
);
+1 -1
View File
@@ -22,7 +22,7 @@ function msg(over: Partial<ChatMessage> = {}): ChatMessage {
ts: '2026-09-09T20:00:00.000Z', ts: '2026-09-09T20:00:00.000Z',
userId: '42', login: 'maxi_2k', displayName: 'maxi_2k', userId: '42', login: 'maxi_2k', displayName: 'maxi_2k',
text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false, text: 'moin', emotes: [], gifs: [], isFirst: false, isReturning: false,
isCommand: false, isMod: false, isSub: false, isBroadcaster: false, isCommand: false, isMod: false, isSub: false, isBroadcaster: false, badges: [],
bits: 0, color: null, ...over, bits: 0, color: null, ...over,
}; };
} }
+9 -1
View File
@@ -161,7 +161,14 @@ export type RestFull = Rest & {
export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean }; export type ApiRole = { id: string; name: string; permissions: string; position: number; color: number; hoist: boolean; mentionable: boolean; managed: boolean };
export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string }; export type ApiOverwrite = { id: string; type: 0 | 1; allow: string; deny: string };
export type ApiChannel = { id: string; name: string; type: number; position: number; parent_id: string | null; topic?: string | null; permission_overwrites?: ApiOverwrite[] }; export type ApiTag = { id?: string; name: string; emoji_id?: string | null; emoji_name: string | null; moderated: boolean };
export type ApiChannel = {
id: string; name: string; type: number; position: number; parent_id: string | null;
topic?: string | null; permission_overwrites?: ApiOverwrite[];
// Nur Foren.
available_tags?: ApiTag[];
default_reaction_emoji?: { emoji_id: string | null; emoji_name: string | null } | null;
};
export type ApiGuild = { export type ApiGuild = {
id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[]; id: string; name: string; owner_id: string; roles: ApiRole[]; features: string[];
// Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei // Die Felder aus GUILD_SETTINGS, damit sich vergleichen laesst, statt bei
@@ -170,6 +177,7 @@ export type ApiGuild = {
explicit_content_filter?: number; explicit_content_filter?: number;
default_message_notifications?: number; default_message_notifications?: number;
system_channel_flags?: number; system_channel_flags?: number;
system_channel_id?: string | null;
}; };
export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string }; export type ApiWebhook = { id: string; name: string; channel_id: string; url?: string; token?: string };
export type ApiUser = { id: string; username: string; bot?: boolean }; export type ApiUser = { id: string; username: string; bot?: boolean };
+39 -1
View File
@@ -7,7 +7,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts'; import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts';
import { applyPlan, type Step } from './apply.ts'; import { applyPlan, mergeTags, type Step } from './apply.ts';
import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts'; import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts';
import { str } from './permissions.ts'; import { str } from './permissions.ts';
@@ -504,3 +504,41 @@ test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => {
assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1); assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1);
assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten'); assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten');
}); });
// ---------------------------------------------------------------- Foren
test('ein Forum bekommt Tags und Standard-Reaktion aus dem Bauplan', async () => {
const api = new FakeDiscord();
await run(api);
const plan = CHANNELS.find((c) => c.key === 'spielvorschlaege')!;
const forum = live(api, 'spielvorschlaege')!;
assert.equal(forum.type, CH.FORUM);
assert.deepEqual(forum.available_tags?.map((t) => t.name), plan.tags?.map((t) => t.name));
assert.equal(forum.default_reaction_emoji?.emoji_name, plan.reaction);
});
test('ein Textkanal gleichen Namens wird nicht fuer das Forum gehalten', async () => {
// Discord kann einen Textkanal nicht in ein Forum umwandeln. Der alte steht
// also daneben, bis ihn jemand loescht - angepasst werden darf er nicht.
const api = new FakeDiscord();
api.channels.push({ id: '1', name: '🎮│spielvorschläge', type: CH.TEXT, position: 0, parent_id: null });
await run(api);
assert.equal(api.channels.filter((c) => c.name === '🎮│spielvorschläge').length, 2);
assert.equal(api.channels.find((c) => c.id === '1')?.available_tags, undefined);
});
test('von Hand angelegte Tags bleiben mit ihrer ID stehen', async () => {
const fremd = { id: '77', name: 'Horror', emoji_name: '👻', moderated: false };
const eigen = { id: '78', name: 'gespielt', emoji_name: '✔️', moderated: false };
const out = mergeTags([fremd, eigen], [
{ name: 'Gespielt', emoji: '✅', moderated: true },
{ name: 'Eher nicht', emoji: '❌', moderated: true },
]);
assert.ok(out);
assert.deepEqual(out.find((t) => t.name === 'Horror'), fremd);
const g = out.find((t) => t.name === 'Gespielt')!;
assert.equal(g.id, '78', 'ohne ID verlieren die Beitraege ihre Markierung');
assert.equal(g.emoji_name, '✅');
assert.ok(out.find((t) => t.name === 'Eher nicht'));
assert.equal(mergeTags(out, [{ name: 'Gespielt', emoji: '✅', moderated: true }]), null);
});
+41 -5
View File
@@ -11,14 +11,14 @@
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { updateSecrets } from '#twitch/secrets.ts'; import { updateSecrets } from '#twitch/secrets.ts';
import { import {
CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiUser, CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiTag, type ApiUser,
type ApiWebhook, type Rest, type ApiWebhook, type Rest,
} from './api.ts'; } from './api.ts';
import { str } from './permissions.ts'; import { str } from './permissions.ts';
import { selfRoleComponents } from './selfroles.ts'; import { selfRoleComponents } from './selfroles.ts';
import { import {
AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, WELCOME, AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, GUILD_SETTINGS, ROLES, SYSTEM_CHANNEL, WELCOME,
type CategoryPlan, type ChannelPlan, type OverwritePlan, type WebhookSlot, type CategoryPlan, type ChannelPlan, type OverwritePlan, type TagPlan, type WebhookSlot,
} from './plan.ts'; } from './plan.ts';
const log = logger('discord:setup'); const log = logger('discord:setup');
@@ -241,7 +241,30 @@ function kindOf(type: number): string {
} }
/** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */ /** Kategorie und Kanal unterscheiden sich fuers Auftragen nur im Typ. */
type AnyPlan = CategoryPlan & { type: number; topic?: string }; type AnyPlan = CategoryPlan & { type: number; topic?: string; tags?: TagPlan[]; reaction?: string };
/**
* Tags eines Forums: die aus dem Plan nachziehen, fremde stehen lassen.
* Discord ersetzt beim PATCH die ganze Liste, deshalb gehen die vorhandenen
* mit ihrer ID wieder mit - ohne ID waere es ein neuer Tag, und Beitraege
* verloeren ihre Markierung. Gibt null zurueck, wenn alles passt.
*/
export function mergeTags(existing: ApiTag[], plan: TagPlan[]): ApiTag[] | null {
let changed = false;
const out = existing.map((t) => ({ ...t }));
for (const p of plan) {
const want = { name: p.name, emoji_name: p.emoji, moderated: p.moderated ?? false };
const hit = out.find((t) => eq(t.name, p.name));
if (!hit) {
out.push(want);
changed = true;
} else if (hit.name !== want.name || hit.emoji_name !== want.emoji_name || hit.moderated !== want.moderated) {
Object.assign(hit, want, { emoji_id: null });
changed = true;
}
}
return changed ? out : null;
}
async function applyChannel( async function applyChannel(
api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean, api: Rest, guild: ApiGuild, existing: ApiChannel[], steps: Step[], dry: boolean,
@@ -268,6 +291,8 @@ async function applyChannel(
...(parentId ? { parent_id: parentId } : {}), ...(parentId ? { parent_id: parentId } : {}),
...(plan.topic ? { topic: plan.topic } : {}), ...(plan.topic ? { topic: plan.topic } : {}),
...(overwrites.length ? { permission_overwrites: overwrites } : {}), ...(overwrites.length ? { permission_overwrites: overwrites } : {}),
...(plan.tags?.length ? { available_tags: mergeTags([], plan.tags) } : {}),
...(plan.reaction ? { default_reaction_emoji: { emoji_id: null, emoji_name: plan.reaction } } : {}),
}, 'Kanal aus dem Bauplan'); }, 'Kanal aus dem Bauplan');
steps.push({ kind, name: plan.name, change: 'erstellt' }); steps.push({ kind, name: plan.name, change: 'erstellt' });
return made; return made;
@@ -280,6 +305,11 @@ async function applyChannel(
if (parentId && found.parent_id !== parentId) patch['parent_id'] = parentId; if (parentId && found.parent_id !== parentId) patch['parent_id'] = parentId;
const merged = mergeOverwrites(found.permission_overwrites ?? [], plan.overwrites ?? [], roleId); const merged = mergeOverwrites(found.permission_overwrites ?? [], plan.overwrites ?? [], roleId);
if (merged) patch['permission_overwrites'] = merged; if (merged) patch['permission_overwrites'] = merged;
const tags = plan.tags?.length ? mergeTags(found.available_tags ?? [], plan.tags) : null;
if (tags) patch['available_tags'] = tags;
if (plan.reaction && found.default_reaction_emoji?.emoji_name !== plan.reaction) {
patch['default_reaction_emoji'] = { emoji_id: null, emoji_name: plan.reaction };
}
if (Object.keys(patch).length === 0) { if (Object.keys(patch).length === 0) {
steps.push({ kind, name: plan.name, change: 'unveraendert' }); steps.push({ kind, name: plan.name, change: 'unveraendert' });
@@ -525,7 +555,13 @@ export async function applyPlan(
await applyWebhooks(api, channel, plan, steps, dry, urls); await applyWebhooks(api, channel, plan, steps, dry, urls);
} }
// 4. AutoMod und Begruessung - beides braucht Kanaele, die es geben muss. // 4. Beitrittsmeldungen, AutoMod und Begruessung - alles braucht Kanaele,
// die es geben muss.
const system = made.get(SYSTEM_CHANNEL);
if (system && guild.system_channel_id !== system.id) {
if (!dry) await api.patch(`/guilds/${guild.id}`, { system_channel_id: system.id }, 'Beitrittsmeldungen aus dem Bauplan');
steps.push({ kind: 'Server', name: `Beitrittsmeldungen nach ${system.name}`, change: dry ? 'erstellt' : 'angepasst' });
}
await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry); await applyAutomod(api, guild, made.get('technik-log')?.id ?? null, steps, dry);
const welcome = made.get('willkommen'); const welcome = made.get('willkommen');
if (welcome) { if (welcome) {
+24 -1
View File
@@ -6,7 +6,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { P, bits, names, str } from './permissions.ts'; import { P, bits, names, str } from './permissions.ts';
import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, WELCOME } from './plan.ts'; import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, SYSTEM_CHANNEL, WELCOME } from './plan.ts';
import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts'; import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts';
import { CH } from './api.ts'; import { CH } from './api.ts';
@@ -180,6 +180,16 @@ test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () =>
} }
}); });
test('Beitritte landen in einem eigenen, beschreibbaren Kanal', () => {
// Ohne Angabe nimmt Discord den ersten Kanal, und das war #allgemein.
const c = CHANNELS.find((x) => x.key === SYSTEM_CHANNEL);
assert.ok(c, `${SYSTEM_CHANNEL} gibt es nicht`);
assert.notEqual(c.key, 'allgemein');
// Der Winke-Knopf unter der Meldung schreibt im Namen dessen, der klickt.
const deny = c.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n;
assert.equal(deny & P.SEND_MESSAGES, 0n);
});
// ---------------------------------------------------------------- Merge // ---------------------------------------------------------------- Merge
const ID = (name: string): string | undefined => const ID = (name: string): string | undefined =>
@@ -334,3 +344,16 @@ test('die AutoMod-Regel kennt ihren frueheren Namen', () => {
assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'), assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'),
'sonst steht die Regel nach der Umbenennung zweimal da'); 'sonst steht die Regel nach der Umbenennung zweimal da');
}); });
test('Tags und Standard-Reaktion stehen nur an Foren und passen in Discords Grenzen', () => {
for (const c of CHANNELS) {
if (c.type !== CH.FORUM) {
assert.equal(c.tags, undefined, `${c.name}: Tags gibt es nur in Foren`);
assert.equal(c.reaction, undefined, `${c.name}: Standard-Reaktion gibt es nur in Foren`);
continue;
}
assert.ok((c.tags?.length ?? 0) <= 20, `${c.name}: hoechstens 20 Tags`);
for (const t of c.tags ?? []) assert.ok(t.name.length <= 20, `Tag ${t.name} ist laenger als 20 Zeichen`);
assert.ok((c.topic?.length ?? 0) <= 4096, `${c.name}: Richtlinien zu lang`);
}
});
+73
View File
@@ -49,16 +49,30 @@ export type CategoryPlan = {
overwrites?: OverwritePlan[]; overwrites?: OverwritePlan[];
}; };
/** Ein Stichwort, mit dem Forenbeitraege markiert werden. */
export type TagPlan = {
/** Hoechstens 20 Zeichen, sonst lehnt Discord ab. */
name: string;
emoji: string;
/** Setzen duerfen nur Mods (MANAGE_THREADS), nicht der Verfasser. */
moderated?: boolean;
};
export type ChannelPlan = { export type ChannelPlan = {
key: string; key: string;
name: string; name: string;
aliases?: string[]; aliases?: string[];
type: number; type: number;
/** Bei Foren sind das die Richtlinien, die ueber jedem neuen Beitrag stehen. */
topic?: string; topic?: string;
/** `key` einer Kategorie aus CATEGORIES. */ /** `key` einer Kategorie aus CATEGORIES. */
category?: string; category?: string;
overwrites?: OverwritePlan[]; overwrites?: OverwritePlan[];
webhooks?: Array<{ name: string; slot: WebhookSlot }>; webhooks?: Array<{ name: string; slot: WebhookSlot }>;
/** Nur Foren. Von Hand angelegte Tags bleiben daneben stehen. */
tags?: TagPlan[];
/** Nur Foren: die Reaktion, die unter jedem Beitrag schon angeboten wird. */
reaction?: string;
}; };
// ---------------------------------------------------------------- Rechte // ---------------------------------------------------------------- Rechte
@@ -184,6 +198,17 @@ export const CHANNELS: ChannelPlan[] = [
topic: 'Was das hier ist und die drei Regeln.', topic: 'Was das hier ist und die drei Regeln.',
overwrites: READONLY, overwrites: READONLY,
}, },
{
// Hier landen Discords Beitrittsmeldungen (SYSTEM_CHANNEL). Beschreibbar,
// damit der Winke-Knopf unter der Meldung funktioniert - der antwortet im
// Namen dessen, der klickt.
key: 'begruessung',
name: '🙋│begrüßung',
aliases: ['begrüßung', 'begruessung'],
type: CH.TEXT,
category: 'info',
topic: 'Wer neu ist, taucht hier auf. Sag Hallo.',
},
{ {
key: 'ankuendigungen', key: 'ankuendigungen',
name: '📢│ankündigungen', name: '📢│ankündigungen',
@@ -214,6 +239,44 @@ export const CHANNELS: ChannelPlan[] = [
topic: 'Gute Momente aus dem Stream. Der Clip-Knopf postet hier hin.', topic: 'Gute Momente aus dem Stream. Der Clip-Knopf postet hier hin.',
webhooks: [{ name: 'Clips', slot: 'clipsWebhook' }], webhooks: [{ name: 'Clips', slot: 'clipsWebhook' }],
}, },
{
// Von Abdu als Textkanal angelegt, seit 2026-10-17 ein Forum: ein Beitrag
// je Spiel, abgestimmt wird per Daumen. Im Textkanal schlaegt nach drei
// Wochen jemand dasselbe Spiel noch einmal vor, weil der erste Vorschlag
// weggescrollt ist. Der erste Satz der Richtlinien ist Abdus eigener.
// Die Stand-Tags setzen nur Mods, sonst markiert jeder sein Spiel als
// "wird gespielt".
key: 'spielvorschlaege',
name: '🎮│spielvorschläge',
aliases: ['spielvorschläge', 'spielvorschlaege'],
type: CH.FORUM,
category: 'community',
topic: 'Ihr wollt sehen wie ich Spiele zocke, die euch gefallen? Schreibt gerne eure Vorschläge rein!\n\n' +
'Ein Beitrag pro Spiel. Gibt es das Spiel schon, Daumen hoch drücken statt noch einmal posten - ' +
'so sehe ich, was ihr am meisten sehen wollt.',
reaction: '👍',
tags: [
{ name: 'Wird gespielt', emoji: '🎮', moderated: true },
{ name: 'Gespielt', emoji: '✅', moderated: true },
{ name: 'Eher nicht', emoji: '❌', moderated: true },
],
},
{
// Ebenfalls von Abdu angelegt und ebenfalls ein Forum: ein Beitrag je
// Clip, damit man sieht, was schon dran war.
key: 'reaction',
name: '👀│reaction-einsendungen',
aliases: ['reaction-einsendungen'],
type: CH.FORUM,
category: 'community',
topic: 'Ihr habt Clips oder Videos worauf ich reagieren sollte? Schickt hier die Clips rein!\n\n' +
'Ein Beitrag pro Clip, Link in den Beitrag.',
reaction: '👍',
tags: [
{ name: 'Kommt dran', emoji: '🔜', moderated: true },
{ name: 'Angeschaut', emoji: '✅', moderated: true },
],
},
{ {
key: 'voice', key: 'voice',
name: '🔊 Zusammen zocken', name: '🔊 Zusammen zocken',
@@ -260,6 +323,14 @@ export const GUILD_SETTINGS = {
system_channel_flags: 4, // Einrichtungstipps unterdruecken, Beitritte zeigen system_channel_flags: 4, // Einrichtungstipps unterdruecken, Beitritte zeigen
}; };
/**
* `key` des Kanals, in den Discord "X ist beigetreten" schreibt. Ohne Angabe
* nimmt Discord den ersten Kanal des Servers, und das war #allgemein: dort
* gingen die Beitritte zwischen den Gespraechen unter, und umgekehrt.
* Steht nicht in GUILD_SETTINGS, weil die ID erst nach den Kanaelen feststeht.
*/
export const SYSTEM_CHANNEL = 'begruessung';
/** /**
* Wird einmalig in #willkommen gepostet, wenn der Kanal noch leer ist. * Wird einmalig in #willkommen gepostet, wenn der Kanal noch leer ist.
* {key} wird durch die Erwaehnung des Kanals mit diesem `key` ersetzt. * {key} wird durch die Erwaehnung des Kanals mit diesem `key` ersetzt.
@@ -276,6 +347,8 @@ Das hier ist der Ort zwischen den Streams. Wer mitliest, gehört dazu.
**Wo was passiert** **Wo was passiert**
> {allgemein} - alles andere > {allgemein} - alles andere
> {clips} - die guten Momente > {clips} - die guten Momente
> {spielvorschlaege} - was Abdu mal spielen sollte
> {reaction} - Clips, auf die Abdu reagieren soll
> {ankuendigungen} - Stream-Start und die Zusammenfassung danach > {ankuendigungen} - Stream-Start und die Zusammenfassung danach
**Bescheid wissen, wenn der Stream startet** **Bescheid wissen, wenn der Stream startet**
+10 -3
View File
@@ -11,9 +11,13 @@ import { forever } from '#core/retry.ts';
const log = logger('obs'); const log = logger('obs');
/** Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs. */ /**
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, outputs: 64 } as const; * Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs,
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.outputs; * dazu Inputs fuer die Stummschalter auf dem Pult.
*/
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, inputs: 8, outputs: 64 } as const;
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes |
SUBSCRIPTIONS.inputs | SUBSCRIPTIONS.outputs;
/** /**
* Antwort auf die Authentifizierungsaufforderung. * Antwort auf die Authentifizierungsaufforderung.
@@ -31,6 +35,8 @@ export type ObsEventHandlers = {
onSceneChanged?: (scene: string) => void; onSceneChanged?: (scene: string) => void;
onIdentified?: () => void; onIdentified?: () => void;
onClosed?: () => void; onClosed?: () => void;
/** Jedes Ereignis, auch die oben schon einzeln gemeldeten. */
onEvent?: (type: string, data: Record<string, unknown>) => void;
}; };
export type ObsClient = { export type ObsClient = {
@@ -105,6 +111,7 @@ export function createObs(
} else if (type === 'CurrentProgramSceneChanged') { } else if (type === 'CurrentProgramSceneChanged') {
handlers.onSceneChanged?.(String(data['sceneName'] ?? '')); handlers.onSceneChanged?.(String(data['sceneName'] ?? ''));
} }
handlers.onEvent?.(type, data);
break; break;
} }
case 7: { // RequestResponse case 7: { // RequestResponse
+2
View File
@@ -41,6 +41,8 @@ export type ChatMessage = {
isMod: boolean; isMod: boolean;
isSub: boolean; isSub: boolean;
isBroadcaster: boolean; isBroadcaster: boolean;
/** Twitchs Abzeichen in Anzeigereihenfolge, z. B. moderator/1, subscriber/3012. */
badges: Array<{ set: string; version: string }>;
bits: number; bits: number;
color: string | null; color: string | null;
}; };
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { badgeKarte, badgesAufloesen } from './badges.ts';
const v = (id: string, url: string) =>
({ id, title: url, image_url_1x: '', image_url_2x: url, image_url_4x: '' });
test('Kanal-Abzeichen ueberdecken die globalen', () => {
const karte = badgeKarte(
[{ set_id: 'subscriber', versions: [v('0', 'global-sub')] }, { set_id: 'moderator', versions: [v('1', 'mod')] }],
[{ set_id: 'subscriber', versions: [v('0', 'kanal-sub'), v('3012', 'kanal-sub-t3')] }],
);
assert.deepEqual(
badgesAufloesen(karte, [{ set: 'moderator', version: '1' }, { set: 'subscriber', version: '0' }])
.map((b) => b.url),
['mod', 'kanal-sub'],
);
assert.equal(badgesAufloesen(karte, [{ set: 'subscriber', version: '3012' }])[0]?.url, 'kanal-sub-t3');
});
test('unbekannte Abzeichen fallen weg', () => {
assert.deepEqual(badgesAufloesen(new Map(), [{ set: 'vip', version: '1' }]), []);
});
+55
View File
@@ -0,0 +1,55 @@
// Abzeichen im Chat als Bilder. IRC liefert nur `subscriber/3012`, das Bild
// dazu kennt nur Helix. Die Kanal-Abzeichen (eigene Abo-Stufen) ueberdecken
// die globalen gleichen Namens - so zeigt Twitch sie auch an.
import { logger } from '#core/logger.ts';
import { helix, type HelixBadgeSet } from './helix.ts';
const log = logger('badges');
export type BadgeBild = { url: string; title: string };
export type BadgeKarte = Map<string, BadgeBild>;
export function badgeKarte(global: HelixBadgeSet[], kanal: HelixBadgeSet[]): BadgeKarte {
const karte: BadgeKarte = new Map();
for (const set of [...global, ...kanal]) {
for (const v of set.versions) {
// 2x ist 36 px: bei 19 px Schrift im Overlay scharf, auch skaliert.
karte.set(`${set.set_id}/${v.id}`, { url: v.image_url_2x, title: v.title });
}
}
return karte;
}
/** Unbekannte Abzeichen fallen weg, statt als kaputtes Bild zu erscheinen. */
export function badgesAufloesen(
karte: BadgeKarte, badges: Array<{ set: string; version: string }>,
): BadgeBild[] {
return badges.flatMap((b) => {
const bild = karte.get(`${b.set}/${b.version}`);
return bild ? [bild] : [];
});
}
export function createBadges(): {
laden: (broadcasterId: string | null) => Promise<void>;
aufloesen: (badges: Array<{ set: string; version: string }>) => BadgeBild[];
} {
let karte: BadgeKarte = new Map();
return {
async laden(broadcasterId) {
try {
const [global, kanal] = await Promise.all([
helix.chatBadges(),
broadcasterId ? helix.chatBadges(broadcasterId) : Promise.resolve([]),
]);
karte = badgeKarte(global, kanal);
log.info(`Abzeichen geladen: ${karte.size}`);
} catch (err) {
// Die alte Karte bleibt stehen. Ohne Karte zeigt der Chat eben keine.
log.warn(`Abzeichen nicht geladen: ${(err as Error).message}`);
}
},
aufloesen: (badges) => badgesAufloesen(karte, badges),
};
}
+17
View File
@@ -47,6 +47,11 @@ export type HelixStream = {
title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string; title: string; viewer_count: number; started_at: string; language: string; thumbnail_url: string;
}; };
export type HelixBadgeSet = {
set_id: string;
versions: Array<{ id: string; title: string; image_url_1x: string; image_url_2x: string; image_url_4x: string }>;
};
export type HelixUser = { id: string; login: string; display_name: string }; export type HelixUser = { id: string; login: string; display_name: string };
export type HelixReward = { export type HelixReward = {
@@ -220,6 +225,18 @@ export const helix = {
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope); return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
}, },
/**
* Abzeichen-Bilder, ohne `broadcasterId` die globalen (Moderator, VIP,
* Broadcaster ...), mit die des Kanals (Abo-Stufen, Bits). Ein App-Token
* reicht fuer beides.
*/
async chatBadges(broadcasterId?: string): Promise<HelixBadgeSet[]> {
const r = await call<{ data: HelixBadgeSet[] }>('broadcaster',
broadcasterId ? `/chat/badges?broadcaster_id=${broadcasterId}` : '/chat/badges/global',
{}, { appOk: true });
return r.data;
},
async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> { async createClip(broadcasterId: string): Promise<{ id: string; edit_url: string }> {
const r = await call<{ data: Array<{ id: string; edit_url: string }> }>( const r = await call<{ data: Array<{ id: string; edit_url: string }> }>(
'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' }); 'broadcaster', `/clips?broadcaster_id=${broadcasterId}`, { method: 'POST' });
+10 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseLine, toChatMessage, parseGifs } from './irc.ts'; import { parseLine, toChatMessage, parseGifs, parseBadges } from './irc.ts';
const LINE = const LINE =
'@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' + '@badge-info=subscriber/8;badges=broadcaster/1,subscriber/6;color=#1E90FF;' +
@@ -28,6 +28,9 @@ test('uebersetzt in eine ChatMessage', () => {
assert.ok(msg); assert.ok(msg);
assert.equal(msg.userId, '98765'); assert.equal(msg.userId, '98765');
assert.equal(msg.login, 'nic_ooo'); assert.equal(msg.login, 'nic_ooo');
assert.deepEqual(msg.badges, [
{ set: 'broadcaster', version: '1' }, { set: 'subscriber', version: '6' },
]);
assert.equal(msg.displayName, 'Nic_OOo'); assert.equal(msg.displayName, 'Nic_OOo');
assert.equal(msg.isBroadcaster, true); assert.equal(msg.isBroadcaster, true);
assert.equal(msg.isSub, true); assert.equal(msg.isSub, true);
@@ -104,3 +107,9 @@ test('unvollstaendige oder unsinnige Tags werden verworfen', () => {
assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`); assert.deepEqual(parseGifs(kaputt), [], `"${kaputt}" haette verworfen gehoert`);
} }
}); });
test('Abzeichen: leer und kaputt ergeben nichts', () => {
assert.deepEqual(parseBadges(undefined), []);
assert.deepEqual(parseBadges(''), []);
assert.deepEqual(parseBadges('vip/1,kaputt'), [{ set: 'vip', version: '1' }]);
});
+10
View File
@@ -111,6 +111,15 @@ function parseEmotes(tag: string | undefined, text: string): ChatMessage['emotes
return out; return out;
} }
/** `broadcaster/1,subscriber/3012` -> [{ set, version }], Reihenfolge wie bei Twitch. */
export function parseBadges(tag: string | undefined): Array<{ set: string; version: string }> {
if (!tag) return [];
return tag.split(',').flatMap((teil) => {
const [set, version] = teil.split('/');
return set && version ? [{ set, version }] : [];
});
}
export function toChatMessage(m: IrcMessage): ChatMessage | null { export function toChatMessage(m: IrcMessage): ChatMessage | null {
if (m.command !== 'PRIVMSG') return null; if (m.command !== 'PRIVMSG') return null;
const text = m.params[1] ?? ''; const text = m.params[1] ?? '';
@@ -133,6 +142,7 @@ export function toChatMessage(m: IrcMessage): ChatMessage | null {
isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'), isMod: m.tags['mod'] === '1' || badges.includes('broadcaster'),
isSub: m.tags['subscriber'] === '1', isSub: m.tags['subscriber'] === '1',
isBroadcaster: badges.includes('broadcaster'), isBroadcaster: badges.includes('broadcaster'),
badges: parseBadges(badges),
bits: Number(m.tags['bits'] ?? 0), bits: Number(m.tags['bits'] ?? 0),
color: m.tags['color'] || null, color: m.tags['color'] || null,
}; };
+8
View File
@@ -59,7 +59,15 @@ export type SecretStore = {
* Ausrollen neu. * Ausrollen neu.
*/ */
sessionKey?: string; sessionKey?: string;
/**
* Mit diesem Schluessel waehlt sich der Stream-PC beim Hub ein und
* nimmt Befehle fuer OBS entgegen. Anders als overlayKey schaltet er
* etwas, steht deshalb nie in einer Adresse.
*/
fernKey?: string;
}; };
/** Auf dem Stream-PC: wo der Hub steht und womit man sich dort meldet. */
fern?: { url: string; key: string };
bluesky?: { handle: string; appPassword: string }; bluesky?: { handle: string; appPassword: string };
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
update?: { token: string }; update?: { token: string };
+23 -5
View File
@@ -72,15 +72,31 @@ const COMPONENTS: Component[] = [
}, },
]; ];
/**
* Das neueste Archiv, das zum Muster passt. Bewusst nicht nur `releases/latest`:
* whisper.cpp legt seit 2026-09 die Programme in eine eigene Veroeffentlichung
* `b<nummer>` am selben Tag wie die Version, und die Version selbst (z. B.
* v1.9.5, "latest") hat gar keine Archive. Die `b`-Staende sind mal als
* Vorabversion markiert, mal nicht - wer Vorabversionen ueberspringt, landet
* bei einem Stand von vor zwei Versionen. Der erste Lauf der Pipeline ist
* genau daran gescheitert.
*/
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> { async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, { const res = await fetch(`https://api.github.com/repos/${repoName}/releases?per_page=30`, {
headers: { 'user-agent': 'abdulive-build' }, headers: { 'user-agent': 'abdulive-build' },
}); });
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`); if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> }; const rels = await res.json() as Array<{
tag_name: string; draft: boolean;
assets: Array<{ name: string; browser_download_url: string }>;
}>;
// Die API liefert die neuesten zuerst.
for (const rel of rels) {
if (rel.draft) continue;
const asset = rel.assets.find((a) => pattern.test(a.name)); const asset = rel.assets.find((a) => pattern.test(a.name));
if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`); if (asset) return asset.browser_download_url;
return asset.browser_download_url; }
throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
} }
// ---------------------------------------------------------------- Werkzeug // ---------------------------------------------------------------- Werkzeug
@@ -159,7 +175,9 @@ async function main(): Promise<void> {
// html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in // html/css/js gehoeren seit den Overlay-Seiten dazu: die liegen in
// apps/overlay-web/public und werden zur Laufzeit von der Platte // apps/overlay-web/public und werden zur Laufzeit von der Platte
// gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay. // gelesen. Fehlen sie im Paket, liefert der Server 404 statt Overlay.
if (!/\.(ts|sql|json|html|css|js|svg|png|woff2)$/.test(rel)) continue; // Dieselben Endungen, die der Overlay-Server ausliefert (TYPES in
// apps/overlay-server/src/server.ts) - sonst fehlt z. B. das Manifest des Pults.
if (!/\.(ts|sql|json|html|css|js|svg|png|jpg|woff2|webmanifest)$/.test(rel)) continue;
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) }); files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
} }
} }
+2 -2
View File
@@ -13,12 +13,12 @@
# Umgebung (mit Vorgaben): # Umgebung (mit Vorgaben):
# PVE_HOST=192.168.2.20 Proxmox-Host # PVE_HOST=192.168.2.20 Proxmox-Host
# CT=141 Container-ID # CT=141 Container-ID
# STIMME=de_DE-mls-medium Name aus huggingface.co/rhasspy/piper-voices # STIMME=de_DE-thorsten-medium Name aus huggingface.co/rhasspy/piper-voices
set -euo pipefail set -euo pipefail
PVE_HOST="${PVE_HOST:-192.168.2.20}" PVE_HOST="${PVE_HOST:-192.168.2.20}"
CT="${CT:-141}" CT="${CT:-141}"
STIMME="${STIMME:-de_DE-mls-medium}" STIMME="${STIMME:-de_DE-thorsten-medium}"
# de_DE-thorsten-medium -> de/de_DE/thorsten/medium # de_DE-thorsten-medium -> de/de_DE/thorsten/medium
IFS=- read -r LOCALE SPRECHER QUALI <<< "$STIMME" IFS=- read -r LOCALE SPRECHER QUALI <<< "$STIMME"
+133
View File
@@ -0,0 +1,133 @@
// Zeichnet die App-Symbole fuer das Pult (PWA) und schreibt sie nach
// apps/overlay-web/public/icons/. Ohne Grafikprogramm und ohne Abhaengigkeit:
// ein paar abgerundete Rechtecke, als PNG von Hand kodiert.
//
// Aufruf: node scripts/make-icons.ts
//
// Die PNGs liegen im Repo. Neu zeichnen nur, wenn sich das Motiv aendert.
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const ZIEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
'..', 'apps', 'overlay-web', 'public', 'icons');
type Farbe = [number, number, number];
const hex = (h: string): Farbe =>
[parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const TASTEN: Farbe[] = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d'].map(hex);
const OBEN = hex('#221a3a');
const UNTEN = hex('#0d0b14');
/** Abstand zum Rand eines abgerundeten Rechtecks, negativ heisst innen. */
function rundRect(x: number, y: number, x0: number, y0: number, x1: number, y1: number, r: number): number {
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const hx = (x1 - x0) / 2 - r, hy = (y1 - y0) / 2 - r;
const qx = Math.abs(x - cx) - hx, qy = Math.abs(y - cy) - hy;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
type Motiv = {
/** Hintergrund bis zum Rand (maskable, Apple) oder selbst abgerundet. */
vollflaechig: boolean;
/** Wo die vier Tasten sitzen, als Anteil der Kantenlaenge. */
von: number; bis: number;
};
function zeichne(groesse: number, m: Motiv): Buffer {
const px = Buffer.alloc(groesse * groesse * 4);
const ss = 4; // 4x4 Unterabtastung je Pixel
const spalt = (m.bis - m.von) * 0.09;
const taste = (m.bis - m.von - spalt) / 2;
for (let py = 0; py < groesse; py++) {
for (let pxi = 0; pxi < groesse; pxi++) {
let r = 0, g = 0, b = 0, a = 0;
for (let sy = 0; sy < ss; sy++) {
for (let sx = 0; sx < ss; sx++) {
const x = (pxi + (sx + 0.5) / ss) / groesse;
const y = (py + (sy + 0.5) / ss) / groesse;
const imGrund = m.vollflaechig || rundRect(x, y, 0, 0, 1, 1, 0.22) <= 0;
if (!imGrund) continue;
let c: Farbe = [0, 1, 2].map((i) => OBEN[i]! + (UNTEN[i]! - OBEN[i]!) * y) as Farbe;
for (let i = 0; i < 4; i++) {
const tx = m.von + (i % 2) * (taste + spalt);
const ty = m.von + Math.floor(i / 2) * (taste + spalt);
if (rundRect(x, y, tx, ty, tx + taste, ty + taste, taste * 0.26) <= 0) {
// Oben etwas heller: sieht nach Taste aus statt nach Flaeche.
const hell = 1.12 - 0.22 * ((y - ty) / taste);
c = TASTEN[i]!.map((v) => Math.min(255, v * hell)) as Farbe;
}
}
r += c[0]; g += c[1]; b += c[2]; a += 255;
}
}
const n = ss * ss, o = (py * groesse + pxi) * 4;
// Vormultipliziert gemittelt, damit die Kanten nicht dunkel ausfransen.
const deck = a / n;
px[o] = deck ? Math.round(r / (a / 255)) : 0;
px[o + 1] = deck ? Math.round(g / (a / 255)) : 0;
px[o + 2] = deck ? Math.round(b / (a / 255)) : 0;
px[o + 3] = Math.round(deck);
}
}
return png(groesse, groesse, px);
}
// ---------------------------------------------------------------- PNG
const CRC = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(b: Buffer): number {
let c = 0xffffffff;
for (const v of b) c = CRC[(c ^ v) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function block(typ: string, daten: Buffer): Buffer {
const len = Buffer.alloc(4); len.writeUInt32BE(daten.length);
const td = Buffer.concat([Buffer.from(typ, 'latin1'), daten]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function png(w: number, h: number, rgba: Buffer): Buffer {
const kopf = Buffer.alloc(13);
kopf.writeUInt32BE(w, 0); kopf.writeUInt32BE(h, 4);
kopf[8] = 8; kopf[9] = 6; kopf[10] = 0; kopf[11] = 0; kopf[12] = 0; // 8 bit RGBA
const zeilen = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) rgba.copy(zeilen, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
block('IHDR', kopf),
block('IDAT', zlib.deflateSync(zeilen, { level: 9 })),
block('IEND', Buffer.alloc(0)),
]);
}
// ---------------------------------------------------------------- los
const NORMAL: Motiv = { vollflaechig: false, von: 0.2, bis: 0.8 };
// Maskable: alles Wichtige im Kreis mit 40 % Radius, der Rest wird abgeschnitten.
const MASKE: Motiv = { vollflaechig: true, von: 0.29, bis: 0.71 };
// Apple rundet selbst und mag keine Durchsicht.
const APPLE: Motiv = { vollflaechig: true, von: 0.22, bis: 0.78 };
fs.mkdirSync(ZIEL, { recursive: true });
const dateien: Array<[string, number, Motiv]> = [
['pult-192.png', 192, NORMAL],
['pult-512.png', 512, NORMAL],
['pult-maskable-512.png', 512, MASKE],
['apple-touch-icon.png', 180, APPLE],
];
for (const [name, groesse, motiv] of dateien) {
const daten = zeichne(groesse, motiv);
fs.writeFileSync(path.join(ZIEL, name), daten);
console.log(`${name} ${groesse}x${groesse} ${(daten.length / 1024).toFixed(1)} KB`);
}