Compare commits
19
Commits
0a3d462854
...
main
No files matched your search
@@ -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/
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -838,6 +883,59 @@ async function discordBot(): Promise<void> {
|
|||||||
await bot.run(abort.signal);
|
await bot.run(abort.signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- rewards:anlegen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Kanalpunkt-Belohnung ueber die API anlegen statt im Dashboard.
|
||||||
|
*
|
||||||
|
* Der Grund ist nicht Bequemlichkeit: Twitch laesst nur die App, die eine
|
||||||
|
* Belohnung angelegt hat, deren Einloesungen aendern. Eine im Dashboard
|
||||||
|
* angelegte Belohnung kann das Toolkit weder erstatten noch als erledigt
|
||||||
|
* markieren. Wiederholbar: steht die Belohnung schon, wird nur ihre ID
|
||||||
|
* gezeigt.
|
||||||
|
*/
|
||||||
|
async function rewardsAnlegen(args: string[]): Promise<void> {
|
||||||
|
const wert = (name: string): string | undefined => {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i === -1 ? undefined : args[i + 1];
|
||||||
|
};
|
||||||
|
const titel = wert('--titel');
|
||||||
|
const kosten = Number(wert('--kosten'));
|
||||||
|
if (!titel || !Number.isInteger(kosten) || kosten < 1) {
|
||||||
|
throw new Error('Aufruf: rewards:anlegen --titel "Nachricht vorlesen" --kosten 1000 ' +
|
||||||
|
'[--text] [--hinweis "..."] [--abklingzeit SEKUNDEN]');
|
||||||
|
}
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const { helix } = await import('#twitch/helix.ts');
|
||||||
|
const user = await helix.userByLogin(cfg.channel.login);
|
||||||
|
if (!user) throw new Error(`Kanal ${cfg.channel.login} nicht gefunden`);
|
||||||
|
|
||||||
|
const alle = await helix.customRewards(user.id);
|
||||||
|
const eigene = new Set((await helix.customRewards(user.id, true)).map((r) => r.id));
|
||||||
|
const da = alle.find((r) => r.title.toLowerCase() === titel.toLowerCase());
|
||||||
|
if (da) {
|
||||||
|
console.log(`Steht schon: "${da.title}" (${da.cost} Punkte), ID ${da.id}`);
|
||||||
|
if (!eigene.has(da.id)) {
|
||||||
|
console.log('Achtung: im Dashboard angelegt, nicht vom Toolkit. Erstatten und ' +
|
||||||
|
'Abschliessen gehen damit nicht. Im Dashboard loeschen und hier neu anlegen.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abklingzeit = Number(wert('--abklingzeit') ?? 0);
|
||||||
|
const neu = await helix.createCustomReward(user.id, {
|
||||||
|
title: titel,
|
||||||
|
cost: kosten,
|
||||||
|
...(args.includes('--text') ? { is_user_input_required: true } : {}),
|
||||||
|
...(wert('--hinweis') ? { prompt: wert('--hinweis')! } : {}),
|
||||||
|
...(abklingzeit > 0
|
||||||
|
? { is_global_cooldown_enabled: true, global_cooldown_seconds: abklingzeit } : {}),
|
||||||
|
should_redemptions_skip_request_queue: false,
|
||||||
|
});
|
||||||
|
console.log(`Angelegt: "${neu.title}" (${neu.cost} Punkte), ID ${neu.id}`);
|
||||||
|
console.log('In config.yaml unter rewards.belohnungen mit `id:` eintragen - die ID gewinnt gegen den Titel.');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- help
|
// ---------------------------------------------------------------- help
|
||||||
|
|
||||||
function help(): void {
|
function help(): void {
|
||||||
@@ -848,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)
|
||||||
@@ -860,6 +962,9 @@ abdulive - Werkzeugkasten fuer Abdus Stream
|
|||||||
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
||||||
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
||||||
Kanal ohne Ping, mit --echt in die Ankuendigungen
|
Kanal ohne Ping, mit --echt in die Ankuendigungen
|
||||||
|
rewards:anlegen --titel T --kosten N [--text] [--hinweis H] [--abklingzeit S]
|
||||||
|
Kanalpunkt-Belohnung ueber die API anlegen (noetig
|
||||||
|
zum Erstatten, siehe apps/hub/src/rewards.ts)
|
||||||
help Diese Uebersicht
|
help Diese Uebersicht
|
||||||
|
|
||||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||||
@@ -892,6 +997,7 @@ try {
|
|||||||
case 'render': await renderOne(args[0], args[1]); break;
|
case 'render': await renderOne(args[0], args[1]); break;
|
||||||
case 'discord:setup': await discordSetup(args); break;
|
case 'discord:setup': await discordSetup(args); break;
|
||||||
case 'golive:test': await goLiveTest(args); break;
|
case 'golive:test': await goLiveTest(args); break;
|
||||||
|
case 'rewards:anlegen': await rewardsAnlegen(args); break;
|
||||||
case 'hub': {
|
case 'hub': {
|
||||||
const { runHub } = await import('../hub/src/index.ts');
|
const { runHub } = await import('../hub/src/index.ts');
|
||||||
await runHub();
|
await runHub();
|
||||||
@@ -899,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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# @abdulive/dashboard
|
|
||||||
|
|
||||||
Analytics-Dashboard und Freigabe-Ansicht fuer Clip-Kandidaten.
|
|
||||||
|
|
||||||
Spezifikation: [docs/projects/analytics.md](../../docs/projects/analytics.md)
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAudioAblage } from './audio-ablage.ts';
|
||||||
|
|
||||||
|
const wav = (n: number): Buffer => Buffer.alloc(44 + n);
|
||||||
|
|
||||||
|
test('abgelegter Ton ist unter seiner ID abholbar', () => {
|
||||||
|
const a = createAudioAblage();
|
||||||
|
a.ablegen('x', wav(1));
|
||||||
|
assert.equal(a.holen('x')?.length, 45);
|
||||||
|
assert.equal(a.holen('y'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('laeuft sie voll, faellt das Aelteste heraus', () => {
|
||||||
|
const a = createAudioAblage({ maximal: 2 });
|
||||||
|
a.ablegen('1', wav(1));
|
||||||
|
a.ablegen('2', wav(2));
|
||||||
|
a.ablegen('3', wav(3));
|
||||||
|
assert.equal(a.holen('1'), undefined);
|
||||||
|
assert.ok(a.holen('2'));
|
||||||
|
assert.ok(a.holen('3'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abgelaufener Ton ist weg', async () => {
|
||||||
|
const a = createAudioAblage({ haltbarMs: 5 });
|
||||||
|
a.ablegen('x', wav(1));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
assert.equal(a.holen('x'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leeren wirft alles weg - fuer den Panikschalter', () => {
|
||||||
|
const a = createAudioAblage();
|
||||||
|
a.ablegen('x', wav(1));
|
||||||
|
a.leeren();
|
||||||
|
assert.equal(a.holen('x'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Fertig gesprochene Nachrichten, bis die Browser-Quelle sie abholt.
|
||||||
|
//
|
||||||
|
// Warum nicht direkt im Ereignis: eine WAV von 200 Zeichen hat gut 500 KB.
|
||||||
|
// Der Ereignisstrom geht an alle Browser-Sources, auch an Chat und
|
||||||
|
// Statusleiste - die sollen das nicht mitschleppen. Also geht nur die ID
|
||||||
|
// hinaus, und `/tts` holt sich den Ton einzeln.
|
||||||
|
//
|
||||||
|
// Bewusst im Arbeitsspeicher und kurzlebig: niemand braucht eine vorgelesene
|
||||||
|
// Nachricht von gestern, und fremder Text soll nicht auf der Platte liegen.
|
||||||
|
|
||||||
|
export type AudioAblage = {
|
||||||
|
ablegen: (id: string, wav: Buffer) => void;
|
||||||
|
holen: (id: string) => Buffer | undefined;
|
||||||
|
leeren: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAudioAblage(opts: { maximal?: number; haltbarMs?: number } = {}): AudioAblage {
|
||||||
|
const maximal = opts.maximal ?? 20;
|
||||||
|
const haltbarMs = opts.haltbarMs ?? 10 * 60_000;
|
||||||
|
// Map haelt die Einfuegereihenfolge; das Aelteste steht vorn.
|
||||||
|
const ablage = new Map<string, { wav: Buffer; seit: number }>();
|
||||||
|
|
||||||
|
const aufraeumen = (jetzt: number): void => {
|
||||||
|
for (const [id, e] of ablage) if (jetzt - e.seit > haltbarMs) ablage.delete(id);
|
||||||
|
while (ablage.size > maximal) {
|
||||||
|
const aeltestes = ablage.keys().next().value;
|
||||||
|
if (aeltestes === undefined) break;
|
||||||
|
ablage.delete(aeltestes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ablegen(id, wav) {
|
||||||
|
ablage.set(id, { wav, seit: Date.now() });
|
||||||
|
aufraeumen(Date.now());
|
||||||
|
},
|
||||||
|
holen(id) {
|
||||||
|
aufraeumen(Date.now());
|
||||||
|
return ablage.get(id)?.wav;
|
||||||
|
},
|
||||||
|
leeren: () => ablage.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
+113
-17
@@ -30,11 +30,15 @@ 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 {
|
import {
|
||||||
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||||
} from './rewards.ts';
|
} from './rewards.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';
|
||||||
@@ -93,25 +97,68 @@ 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() });
|
||||||
|
|
||||||
/** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */
|
/**
|
||||||
const erstatten = async (rewardId: string, redemptionId: string): Promise<void> => {
|
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
|
||||||
|
* (CANCELED). Geht nur bei Belohnungen, die das Toolkit selbst angelegt hat
|
||||||
|
* (`abdulive rewards:anlegen`) - sonst lehnt Twitch ab, und es bleibt bei
|
||||||
|
* einer Warnung.
|
||||||
|
*/
|
||||||
|
const einloesungSetzen = async (
|
||||||
|
rewardId: string, redemptionId: string, status: 'FULFILLED' | 'CANCELED',
|
||||||
|
): Promise<void> => {
|
||||||
|
const was = status === 'CANCELED' ? 'Punkte nicht erstattet' : 'Einloesung nicht abgeschlossen';
|
||||||
if (!state.broadcasterId) return;
|
if (!state.broadcasterId) return;
|
||||||
if (!helix.hasScope('channel:manage:redemptions')) {
|
if (!helix.hasScope('channel:manage:redemptions')) {
|
||||||
log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' +
|
log.warn(`${was}: dem verbundenen Konto fehlt ` +
|
||||||
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
|
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`Punkte nicht erstattet: ${(err as Error).message}`);
|
log.warn(`${was}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const erstatten = (rewardId: string, redemptionId: string): Promise<void> =>
|
||||||
|
einloesungSetzen(rewardId, redemptionId, 'CANCELED');
|
||||||
|
|
||||||
const vorlesen = (id: string, name: string, text: string): void => {
|
// ---- Vorlesen. Die Stimme entsteht hier im Hub (Piper), nicht im Browser:
|
||||||
bus.publish({ kind: 'tts', id, name, text });
|
// dort haengt sie am Rechner, der streamt, und kam in OBS amerikanisch.
|
||||||
|
const stimme = {
|
||||||
|
model: stimmenPfad(cfg.rewards.tts.stimme),
|
||||||
|
...(typeof cfg.rewards.tts.sprecher === 'number' ? { speaker: cfg.rewards.tts.sprecher } : {}),
|
||||||
|
};
|
||||||
|
const audio = createAudioAblage();
|
||||||
|
const stimmeFehlt = piperFehlt(stimme);
|
||||||
|
if (stimmeFehlt.length) {
|
||||||
|
log.warn(`Vorlesen ohne Stimme - es fehlt: ${stimmeFehlt.join(', ')}. ` +
|
||||||
|
'Einrichten mit scripts/install-piper.sh. Bis dahin wird nur angezeigt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorlesen = async (
|
||||||
|
id: string, name: string, text: string,
|
||||||
|
/** Fehlt beim Testknopf - dann gibt es bei Twitch nichts abzuschliessen. */
|
||||||
|
einloesung?: { rewardId: string; redemptionId: string },
|
||||||
|
): Promise<void> => {
|
||||||
|
let mitTon = false;
|
||||||
|
try {
|
||||||
|
audio.ablegen(id, await sprechen(text, stimme));
|
||||||
|
mitTon = true;
|
||||||
|
} catch (err) {
|
||||||
|
// Lieber stumm anzeigen als gar nicht: wer Punkte ausgegeben hat, soll
|
||||||
|
// seine Nachricht wenigstens im Bild sehen.
|
||||||
|
log.warn(`Nicht gesprochen, nur angezeigt: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
bus.publish({ kind: 'tts', id, name, text, audio: mitTon });
|
||||||
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
|
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
|
||||||
|
// Auch ohne Ton abgeschlossen: angezeigt wurde es, und eine Einloesung,
|
||||||
|
// die ewig in Twitchs Warteschlange haengt, raeumt niemand auf.
|
||||||
|
if (einloesung) void einloesungSetzen(einloesung.rewardId, einloesung.redemptionId, 'FULFILLED');
|
||||||
};
|
};
|
||||||
|
|
||||||
const behandleEinloesung = (roh: Record<string, unknown>): void => {
|
const behandleEinloesung = (roh: Record<string, unknown>): void => {
|
||||||
@@ -148,11 +195,16 @@ 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');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'vorlesen':
|
case 'vorlesen':
|
||||||
vorlesen(ulid(), e.userName, ent.text);
|
void vorlesen(ulid(), e.userName, ent.text, { rewardId: e.rewardId, redemptionId: e.id });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'warteschlange':
|
case 'warteschlange':
|
||||||
@@ -194,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) => {
|
||||||
@@ -202,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',
|
||||||
@@ -222,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) {
|
||||||
@@ -279,6 +346,8 @@ 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',
|
||||||
spikeArmed: spikes.isArmed(),
|
spikeArmed: spikes.isArmed(),
|
||||||
note: state.chatAnonymous
|
note: state.chatAnonymous
|
||||||
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||||
@@ -291,7 +360,7 @@ export async function runHub(): Promise<void> {
|
|||||||
freigeben: (id) => {
|
freigeben: (id) => {
|
||||||
const w = warteschlange.entnehmen(id);
|
const w = warteschlange.entnehmen(id);
|
||||||
if (!w) return false;
|
if (!w) return false;
|
||||||
vorlesen(w.id, w.name, w.text);
|
void vorlesen(w.id, w.name, w.text, { rewardId: w.rewardId, redemptionId: w.redemptionId });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
ablehnen: (id) => {
|
ablehnen: (id) => {
|
||||||
@@ -307,18 +376,48 @@ export async function runHub(): Promise<void> {
|
|||||||
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
||||||
return alle.length;
|
return alle.length;
|
||||||
},
|
},
|
||||||
|
probe: (name, text) => { void vorlesen(ulid(), name, text); },
|
||||||
|
audio: (id) => audio.holen(id),
|
||||||
still: () => {
|
still: () => {
|
||||||
bus.publish({ kind: 'still' });
|
bus.publish({ kind: 'still' });
|
||||||
|
audio.leeren();
|
||||||
const alle = warteschlange.leeren();
|
const alle = warteschlange.leeren();
|
||||||
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||||
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
|
||||||
@@ -466,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,10 +4,45 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, planFuer,
|
ausEventSub, createDrossel, createWarteschlange, entscheiden, fuerFilter, planFuer,
|
||||||
type Einloesung, type RewardsConfig,
|
wortfilterTreffer, type Einloesung, type RewardsConfig,
|
||||||
} from './rewards.ts';
|
} from './rewards.ts';
|
||||||
|
|
||||||
|
// ---- Wortfilter. Vorgelesen wird ohne Freigabe, also muss er halten.
|
||||||
|
|
||||||
|
const LISTE = ['hitler', 'nazi'];
|
||||||
|
|
||||||
|
test('Wortfilter: Leetspeak, Punkte und gezogene Buchstaben helfen nicht', () => {
|
||||||
|
for (const t of ['H1tl3r war super', 'h.i.t.l.e.r', 'H I T L E R', 'hiiiitlerrr', 'HİTLER']) {
|
||||||
|
assert.equal(wortfilterTreffer(t, LISTE), 'hitler', t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wortfilter: kurze Eintraege nur am Wortanfang, sonst trifft es Harmloses', () => {
|
||||||
|
assert.equal(wortfilterTreffer('ich kann azubi werden', LISTE), undefined);
|
||||||
|
assert.equal(wortfilterTreffer('Gymnasium', LISTE), undefined);
|
||||||
|
assert.equal(wortfilterTreffer('alles Nazis hier', 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', () => {
|
||||||
|
assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fuerFilter: Umlaute und ß werden zu dem, was man hoert', () => {
|
||||||
|
assert.equal(fuerFilter('Größe', false), 'grose');
|
||||||
|
assert.equal(fuerFilter('a-b c', true), 'a b c');
|
||||||
|
});
|
||||||
|
|
||||||
const CFG: RewardsConfig = {
|
const CFG: RewardsConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
belohnungen: [
|
belohnungen: [
|
||||||
|
|||||||
+52
-4
@@ -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 = {
|
||||||
@@ -105,9 +108,7 @@ export function entscheiden(
|
|||||||
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const klein = text.toLowerCase();
|
if (wortfilterTreffer(text, cfg.tts.blockliste)) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||||
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
|
|
||||||
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
|
||||||
|
|
||||||
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
||||||
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
||||||
@@ -117,6 +118,53 @@ export function entscheiden(
|
|||||||
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
|
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Wortfilter
|
||||||
|
|
||||||
|
const LEET: Record<string, string> = {
|
||||||
|
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b',
|
||||||
|
'@': 'a', '$': 's', '!': 'i', '|': 'i',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text auf das bringen, was man hoert: klein, ohne Akzente, Leetspeak
|
||||||
|
* zurueckuebersetzt, Buchstabenketten auf einen gekuerzt. Wer "N 3 G G 3 R"
|
||||||
|
* oder "h.i.t.l.e.r" schreibt, will am Filter vorbei - vorgelesen klingt es
|
||||||
|
* trotzdem wie das Original.
|
||||||
|
*
|
||||||
|
* `mitLuecken` laesst Wortgrenzen als Leerzeichen stehen, sonst wird alles
|
||||||
|
* zusammengezogen.
|
||||||
|
*/
|
||||||
|
export function fuerFilter(text: string, mitLuecken: boolean): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.normalize('NFKD').replace(/\p{M}/gu, '')
|
||||||
|
.replace(/[0134578@$!|]/g, (c) => LEET[c] ?? c)
|
||||||
|
.replace(/[^a-z]+/g, mitLuecken ? ' ' : '')
|
||||||
|
.replace(/([a-z])\1+/g, '$1')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trifft der Text ein Wort der Blockliste?
|
||||||
|
*
|
||||||
|
* Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text
|
||||||
|
* gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur
|
||||||
|
* 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 {
|
||||||
|
const dicht = fuerFilter(text, false);
|
||||||
|
const woerter = fuerFilter(text, true).split(' ');
|
||||||
|
return blockliste.find((eintrag) => {
|
||||||
|
const e = fuerFilter(eintrag, 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Warteschlange
|
// ---------------------------------------------------------------- Warteschlange
|
||||||
|
|
||||||
export type Wartend = {
|
export type Wartend = {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 }; });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -81,13 +120,40 @@ export type OverlayEvent =
|
|||||||
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
||||||
* (apps/hub/src/rewards.ts), nicht hier.
|
* (apps/hub/src/rewards.ts), nicht hier.
|
||||||
*/
|
*/
|
||||||
| { kind: 'tts'; id: string; name: string; text: string }
|
| {
|
||||||
|
kind: 'tts'; id: string; name: string; text: string;
|
||||||
|
/** Liegt unter /tts/audio/<id> bereit. Ohne Ton wird nur angezeigt. */
|
||||||
|
audio?: boolean;
|
||||||
|
}
|
||||||
/** 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
|
||||||
@@ -114,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) {
|
||||||
@@ -127,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.
|
||||||
@@ -149,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
@@ -54,6 +60,8 @@ export const PAGES = [
|
|||||||
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||||
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||||
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
||||||
|
{ path: '/kamera', file: 'kamera.html', w: 520, h: 380,
|
||||||
|
title: 'Kamerarahmen', hint: 'Rahmen um die Facecam mit Zuschauern und Laufzeit. Position 0,0, über der Kamera.' },
|
||||||
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
||||||
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||||
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||||
@@ -62,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;
|
||||||
};
|
};
|
||||||
@@ -77,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;
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +106,19 @@ export type OverlayOptions = {
|
|||||||
ablehnen: (id: string) => boolean;
|
ablehnen: (id: string) => boolean;
|
||||||
alleAblehnen: () => number;
|
alleAblehnen: () => number;
|
||||||
still: () => void;
|
still: () => void;
|
||||||
|
/** Spricht einen Text sofort, ohne Warteschlange. Fuer den Testknopf. */
|
||||||
|
probe: (name: string, text: string) => void;
|
||||||
|
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,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');
|
||||||
@@ -124,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;
|
||||||
}
|
}
|
||||||
@@ -158,25 +212,137 @@ 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
|
||||||
if (page || p === '/events') {
|
// 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 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) {
|
||||||
|
const wav = opts.tts?.audio(audioId);
|
||||||
|
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'audio/wav', 'content-length': wav.length, 'cache-control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(wav);
|
||||||
|
return;
|
||||||
|
}
|
||||||
return sendFile(res, page!.file);
|
return sendFile(res, page!.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 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.
|
||||||
@@ -220,6 +386,13 @@ async function handle(
|
|||||||
const chat = zustand(bus).chat;
|
const chat = zustand(bus).chat;
|
||||||
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||||
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
||||||
|
// Vorlesen geht durch den Hub, damit der Knopf die echte Stimme prueft
|
||||||
|
// und nicht nur die Anzeige.
|
||||||
|
if (ev.kind === 'tts' && opts.tts) {
|
||||||
|
opts.tts.probe(ev.name, ev.text);
|
||||||
|
log.info('Testereignis ausgeloest: tts (mit Stimme)');
|
||||||
|
return antwort(`${knopf.label} ausgelöst.`);
|
||||||
|
}
|
||||||
bus.publish(ev);
|
bus.publish(ev);
|
||||||
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||||
return antwort(`${knopf.label} ausgelöst.`);
|
return antwort(`${knopf.label} ausgelöst.`);
|
||||||
@@ -253,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;
|
||||||
@@ -270,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',
|
||||||
@@ -300,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);
|
||||||
}
|
}
|
||||||
@@ -312,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[] = [];
|
||||||
@@ -328,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();
|
||||||
@@ -404,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">
|
||||||
@@ -428,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.
|
||||||
@@ -443,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"> </p>` : '';
|
<p id="tts-meldung" class="meldung"> </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"> </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)}`;
|
||||||
@@ -467,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>
|
||||||
@@ -501,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>
|
||||||
@@ -542,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 = ' '; 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', {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
+90
-11
@@ -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,23 +82,101 @@ 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`)
|
||||||
|
|
||||||
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||||
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||||
|
|
||||||
Gesprochen wird über `speechSynthesis`, die im Browser eingebaute
|
Gesprochen wird im Hub mit Piper, nicht im Browser. Die eingebaute
|
||||||
Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das
|
Sprachausgabe von OBS' Browser-Quelle sah keine deutsche Stimme und las
|
||||||
Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird
|
amerikanisch; welche Stimmen es gibt, hängt am Rechner, der streamt. Piper
|
||||||
teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet
|
läuft lokal im LXC, kostet nichts, und kein Zuschauertext verlässt das Haus.
|
||||||
einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das
|
Die Seite holt sich die fertige WAV unter `/tts/audio/<id>` und spielt sie
|
||||||
auffallen und nicht stillschweigend fehlen.
|
über ein `<audio>` ab, das sicher über OBS' Tonspur läuft.
|
||||||
|
|
||||||
Einstellungen: `?sprache=de-DE`, `?tempo=1`, `?laut=1`.
|
Kann der Hub nicht sprechen, springt `speechSynthesis` ein, aber nur mit
|
||||||
|
einer deutschen Stimme. Gibt es keine, wird die Nachricht nur angezeigt,
|
||||||
|
mit rotem Hinweis.
|
||||||
|
|
||||||
Vorgelesen wird nur, was auf der Übersichtsseite freigegeben wurde. Nach einem
|
Stimme und Sprecher: `rewards.tts.stimme` und `rewards.tts.sprecher` in der
|
||||||
Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
Konfiguration, eingerichtet mit `scripts/install-piper.sh`.
|
||||||
|
|
||||||
|
Einstellungen: `?laut=1` (0 bis 1). Besser im OBS-Mixer regeln.
|
||||||
|
`?sprache=de-DE` und `?tempo=1` gelten nur für den Notnagel im Browser.
|
||||||
|
|
||||||
|
Mit `freigabe: true` wird nur vorgelesen, was auf der Übersichtsseite
|
||||||
|
freigegeben wurde. Nach einem Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||||
vorzulesen ist schlimmer, als sie zu verpassen.
|
vorzulesen ist schlimmer, als sie zu verpassen.
|
||||||
@@ -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 +
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,227 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Kamerarahmen</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Rahmen um die Facecam, dazu die Werte der alten Statusleiste auf einer
|
||||||
|
* Platte darunter.
|
||||||
|
*
|
||||||
|
* Die Kamera liegt bündig oben links, 480x270 (docs/obs-layout.md). Genau
|
||||||
|
* dieser Bereich wird für die Shorts ausgeschnitten, und das Gameplay ab
|
||||||
|
* x=515. Deshalb liegt hier NICHTS über der Kamera: der Rahmen nutzt nur den
|
||||||
|
* Streifen rechts (480 bis 515) und den freien Platz darunter. Wer den
|
||||||
|
* Rahmen nach innen zieht, hat ihn in jedem Short.
|
||||||
|
*
|
||||||
|
* Oben und links gibt es keinen Rahmen - dort ist der Bildrand.
|
||||||
|
*
|
||||||
|
* In OBS: Browser-Quelle 520x380, Position 0,0, über der Kamera. */
|
||||||
|
:root {
|
||||||
|
--cam-w: 480px;
|
||||||
|
--cam-h: 270px;
|
||||||
|
--dicke: 7px;
|
||||||
|
--abstand: 4px; /* Luft zwischen Kamera und Rahmen */
|
||||||
|
--platte-h: 74px;
|
||||||
|
--verlauf: linear-gradient(135deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Rahmen: ein L um die rechte und untere Kante, außen abgerundet.
|
||||||
|
* Gezeichnet als Fläche mit ausgespartem Inneren, damit der Farbverlauf
|
||||||
|
* ohne Stoß um die Ecke läuft. */
|
||||||
|
#rahmen {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0;
|
||||||
|
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
|
||||||
|
height: calc(var(--cam-h) + var(--abstand) + var(--dicke));
|
||||||
|
border-bottom-right-radius: 22px;
|
||||||
|
background: var(--verlauf);
|
||||||
|
background-size: 300% 300%;
|
||||||
|
animation: wandern 10s linear infinite;
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) top left / calc(var(--cam-w) + var(--abstand)) calc(var(--cam-h) + var(--abstand)) no-repeat,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(169, 112, 255, .45));
|
||||||
|
transition: filter .4s;
|
||||||
|
}
|
||||||
|
@keyframes wandern { to { background-position: 300% 300%; } }
|
||||||
|
|
||||||
|
/* Platte unter der Kamera. Hängt am Rahmen, damit beides als ein Stück
|
||||||
|
* gelesen wird und nicht als zwei Overlays, die zufällig nah beieinander
|
||||||
|
* liegen. */
|
||||||
|
#platte {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: calc(var(--cam-h) + var(--abstand) + var(--dicke));
|
||||||
|
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
|
||||||
|
height: var(--platte-h);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 18px 0 16px;
|
||||||
|
background: var(--tinte-klar);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 16px;
|
||||||
|
box-shadow: var(--schatten), var(--glanz);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links { min-width: 0; }
|
||||||
|
.zeile1 { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.punkt {
|
||||||
|
width: 11px; height: 11px; flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
|
||||||
|
@keyframes klopfen {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
|
||||||
|
70% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
.kanal { font-size: 23px; font-weight: 700; letter-spacing: -.01em; line-height: 1.1; }
|
||||||
|
#kategorie {
|
||||||
|
font-size: 15px; color: var(--schrift-leise); margin-top: 3px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.werte { display: flex; gap: 18px; }
|
||||||
|
.wert { text-align: right; }
|
||||||
|
.wert .zahl {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
|
||||||
|
font: 700 22px/1.1 var(--font-zahl);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.wert svg { width: 17px; height: 17px; opacity: .8; }
|
||||||
|
.wert .beschriftung {
|
||||||
|
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
}
|
||||||
|
.zahl.neu { animation: aufleuchten .8s ease-out; }
|
||||||
|
@keyframes aufleuchten {
|
||||||
|
0% { color: var(--tuerkis); transform: translateY(-3px); }
|
||||||
|
100% { color: inherit; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nicht live: der Rahmen wird ruhig und grau. So sieht man in der
|
||||||
|
* Vorschau sofort, ob der Hub den Stream erkannt hat. */
|
||||||
|
body:not(.live) #rahmen {
|
||||||
|
background: linear-gradient(135deg, #3a3550, #57507a);
|
||||||
|
animation: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aufleuchten bei Abo, Raid, Bits: der Rahmen glüht kurz in der Farbe des
|
||||||
|
* Ereignisses. Die große Einblendung macht alerts.html - das hier ist nur
|
||||||
|
* das Echo an der Kamera, damit der Blick dorthin geht, wo Abdu reagiert. */
|
||||||
|
body.puls #rahmen { animation: wandern 1.2s linear infinite; }
|
||||||
|
body.puls-lila #rahmen { filter: drop-shadow(0 0 22px var(--lila)); }
|
||||||
|
body.puls-pink #rahmen { filter: drop-shadow(0 0 22px var(--pink)); }
|
||||||
|
body.puls-gold #rahmen { filter: drop-shadow(0 0 22px var(--gold)); }
|
||||||
|
body.puls-tuerkis #rahmen { filter: drop-shadow(0 0 22px var(--tuerkis)); }
|
||||||
|
|
||||||
|
/* Nur zum Ansehen im Browser (?probe=1): ein Platzhalter, wo die Kamera
|
||||||
|
* sitzt. In OBS liegt dort das echte Bild. */
|
||||||
|
#kamera-probe {
|
||||||
|
display: none;
|
||||||
|
position: fixed; left: 0; top: 0;
|
||||||
|
width: var(--cam-w); height: var(--cam-h);
|
||||||
|
background:
|
||||||
|
radial-gradient(40% 55% at 50% 60%, #4a3b6b, transparent 70%),
|
||||||
|
linear-gradient(160deg, #2b2440, #17131f);
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
font: 500 14px/1 var(--font-zahl);
|
||||||
|
display: none; place-items: center;
|
||||||
|
}
|
||||||
|
body.probe #kamera-probe { display: grid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="kamera-probe">Facecam 480 × 270</div>
|
||||||
|
<div id="rahmen"></div>
|
||||||
|
<div id="platte">
|
||||||
|
<div class="links">
|
||||||
|
<div class="zeile1">
|
||||||
|
<span class="punkt"></span>
|
||||||
|
<span class="kanal kontur" id="kanal">abdulive_</span>
|
||||||
|
</div>
|
||||||
|
<div id="kategorie">offline</div>
|
||||||
|
</div>
|
||||||
|
<div class="werte">
|
||||||
|
<div class="wert">
|
||||||
|
<span class="zahl" id="zuschauer">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg><span>-</span>
|
||||||
|
</span>
|
||||||
|
<span class="beschriftung">Zuschauer</span>
|
||||||
|
</div>
|
||||||
|
<div class="wert">
|
||||||
|
<span class="zahl" id="laufzeit"><span>-</span></span>
|
||||||
|
<span class="beschriftung">live seit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="getrennt">keine Verbindung</div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (Overlay.param('probe', false)) document.body.classList.add('probe');
|
||||||
|
|
||||||
|
var kategorie = document.getElementById('kategorie');
|
||||||
|
var zuschauer = document.getElementById('zuschauer');
|
||||||
|
var laufzeit = document.getElementById('laufzeit');
|
||||||
|
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||||
|
var seit = null;
|
||||||
|
|
||||||
|
function setze(el, wert) {
|
||||||
|
var ziel = el.lastElementChild;
|
||||||
|
if (ziel.textContent === wert) return;
|
||||||
|
ziel.textContent = wert;
|
||||||
|
el.classList.remove('neu');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('neu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stand(ev) {
|
||||||
|
document.body.classList.toggle('live', !!ev.live);
|
||||||
|
// Die Kategorie statt des Titels: der Titel ist meist zu lang für 490 px
|
||||||
|
// und steht ohnehin auf der Twitch-Seite direkt unter dem Bild.
|
||||||
|
kategorie.textContent = ev.live ? (ev.category || 'live') : 'offline';
|
||||||
|
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
|
||||||
|
seit = ev.live ? ev.startedAt : null;
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
function tick() { laufzeit.lastElementChild.textContent = seit ? Overlay.laufzeit(seit) : '-'; }
|
||||||
|
setInterval(tick, 10000);
|
||||||
|
|
||||||
|
var FARBE = {
|
||||||
|
sub: 'lila', resub: 'lila', subgift: 'pink', massgift: 'pink',
|
||||||
|
raid: 'tuerkis', cheer: 'gold', follow: 'tuerkis', reward: 'gold',
|
||||||
|
};
|
||||||
|
var pulsTimer = null;
|
||||||
|
function puls(farbe) {
|
||||||
|
var b = document.body;
|
||||||
|
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
|
||||||
|
void b.offsetWidth;
|
||||||
|
b.classList.add('puls', 'puls-' + farbe);
|
||||||
|
clearTimeout(pulsTimer);
|
||||||
|
pulsTimer = setTimeout(function () {
|
||||||
|
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay.on('stream', stand);
|
||||||
|
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||||
|
Overlay.on('alert', function (ev) { puls(FARBE[ev.type] || 'lila'); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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" }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
}
|
}
|
||||||
.was { font-size: 22px; line-height: 1.35; }
|
.was { font-size: 22px; line-height: 1.35; }
|
||||||
|
|
||||||
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
|
/* Ein Hinweis fuer den Fall, dass eine Nachricht ohne Ton bleibt. Im Stream
|
||||||
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
|
* soll das auffallen, nicht stillschweigend fehlen. */
|
||||||
#fehlt {
|
#fehlt {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 16px; top: 16px;
|
left: 16px; top: 16px;
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="fehlt">
|
<div id="fehlt">
|
||||||
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
|
Kein Ton: der Hub konnte nicht sprechen, und hier gibt es keine deutsche
|
||||||
Vorgelesen wird nichts.
|
Stimme. Die Nachricht wird nur angezeigt.
|
||||||
</div>
|
</div>
|
||||||
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
|
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
|
||||||
|
|
||||||
@@ -65,29 +65,31 @@
|
|||||||
|
|
||||||
var sprache = Overlay.param('sprache', 'de-DE');
|
var sprache = Overlay.param('sprache', 'de-DE');
|
||||||
var tempo = Overlay.param('tempo', 1);
|
var tempo = Overlay.param('tempo', 1);
|
||||||
var lautstaerke = Overlay.param('laut', 1);
|
var lautstaerke = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
|
||||||
|
var schluessel = Overlay.param('key', '');
|
||||||
|
var fehltEl = document.getElementById('fehlt');
|
||||||
var warteschlange = [];
|
var warteschlange = [];
|
||||||
var laeuft = false;
|
var laeuft = false;
|
||||||
|
/* Was gerade klingt, damit der Panikschalter es abbrechen kann. */
|
||||||
|
var aktuell = null;
|
||||||
|
|
||||||
/* Ob die Browser-Quelle ueberhaupt sprechen kann, weiss man erst zur
|
/* Gesprochen wird im Hub (Piper), hier wird nur abgespielt. Das hat zwei
|
||||||
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
|
* Gruende: die eingebaute Sprachausgabe von OBS' Browser sah keine deutsche
|
||||||
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
|
* Stimme und las amerikanisch, und ein <audio> laeuft sicher ueber OBS'
|
||||||
* warum. */
|
* Tonspur statt am Mixer vorbei.
|
||||||
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
|
*
|
||||||
typeof window.SpeechSynthesisUtterance !== 'undefined';
|
* Die Browser-Sprachausgabe bleibt nur als Notnagel, wenn der Hub keinen
|
||||||
if (!kannSprechen) {
|
* Ton liefern konnte - und auch dann nur mit einer deutschen Stimme. Eine
|
||||||
document.getElementById('fehlt').style.display = 'block';
|
* englische Stimme, die deutschen Text radebrecht, ist schlimmer als Stille. */
|
||||||
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
|
function deutscheStimme() {
|
||||||
}
|
if (typeof window.speechSynthesis === 'undefined' ||
|
||||||
|
typeof window.SpeechSynthesisUtterance === 'undefined') return null;
|
||||||
/* Stimmen werden asynchron geladen. Beim ersten Aufruf ist die Liste oft
|
|
||||||
* noch leer - deshalb bei jedem Sprechen neu nachsehen statt einmal am
|
|
||||||
* Anfang. */
|
|
||||||
function stimme() {
|
|
||||||
if (!kannSprechen) return null;
|
|
||||||
var alle = window.speechSynthesis.getVoices() || [];
|
var alle = window.speechSynthesis.getVoices() || [];
|
||||||
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
|
var kurz = sprache.slice(0, 2).toLowerCase();
|
||||||
return passend[0] || alle[0] || null;
|
for (var i = 0; i < alle.length; i++) {
|
||||||
|
if (alle[i].lang && alle[i].lang.toLowerCase().indexOf(kurz) === 0) return alle[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function zeige(name, text) {
|
function zeige(name, text) {
|
||||||
@@ -95,47 +97,71 @@
|
|||||||
wasEl.textContent = text;
|
wasEl.textContent = text;
|
||||||
kasten.classList.add('an');
|
kasten.classList.add('an');
|
||||||
}
|
}
|
||||||
function verstecke() { kasten.classList.remove('an'); }
|
function verstecke() { kasten.classList.remove('an'); fehltEl.style.display = 'none'; }
|
||||||
|
|
||||||
|
/* Geschaetzte Sprechdauer, als Notbremse und fuer die stumme Anzeige. */
|
||||||
|
function dauerMs(text) { return 3000 + text.length * 90; }
|
||||||
|
|
||||||
function naechste() {
|
function naechste() {
|
||||||
var n = warteschlange.shift();
|
var n = warteschlange.shift();
|
||||||
|
aktuell = null;
|
||||||
if (!n) { laeuft = false; verstecke(); return; }
|
if (!n) { laeuft = false; verstecke(); return; }
|
||||||
laeuft = true;
|
laeuft = true;
|
||||||
zeige(n.name, n.text);
|
zeige(n.name, n.text);
|
||||||
|
|
||||||
if (!kannSprechen) {
|
|
||||||
// Wenigstens anzeigen, und nach der geschaetzten Lesedauer weiter.
|
|
||||||
setTimeout(naechste, Math.min(15000, 2000 + n.text.length * 60));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rede = new SpeechSynthesisUtterance(n.text);
|
|
||||||
rede.lang = sprache;
|
|
||||||
rede.rate = tempo;
|
|
||||||
rede.volume = lautstaerke;
|
|
||||||
var v = stimme();
|
|
||||||
if (v) rede.voice = v;
|
|
||||||
|
|
||||||
var weiter = false;
|
var weiter = false;
|
||||||
var gehWeiter = function () {
|
var gehWeiter = function () {
|
||||||
if (weiter) return;
|
if (weiter) return;
|
||||||
weiter = true;
|
weiter = true;
|
||||||
setTimeout(naechste, 400);
|
setTimeout(naechste, 400);
|
||||||
};
|
};
|
||||||
|
// Notbremse: haengt die Wiedergabe, steht die Warteschlange sonst fuer
|
||||||
|
// den Rest des Streams. Grosszuegig bemessen.
|
||||||
|
setTimeout(gehWeiter, 8000 + dauerMs(n.text) * 2);
|
||||||
|
|
||||||
|
if (n.audio) {
|
||||||
|
var ton = new Audio('/tts/audio/' + encodeURIComponent(n.id) +
|
||||||
|
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
|
||||||
|
ton.volume = lautstaerke;
|
||||||
|
ton.onended = gehWeiter;
|
||||||
|
ton.onerror = function () {
|
||||||
|
console.error('[tts] Ton nicht abspielbar', ton.error && ton.error.code);
|
||||||
|
gehWeiter();
|
||||||
|
};
|
||||||
|
aktuell = { stopp: function () { try { ton.pause(); } catch (e) { /* egal */ } } };
|
||||||
|
var p = ton.play();
|
||||||
|
// Ein normaler Browser blockiert Ton ohne vorherigen Klick. OBS nicht -
|
||||||
|
// wer die Seite zum Testen im Browser offen hat, sieht es hier.
|
||||||
|
if (p && p.catch) p.catch(function (e) {
|
||||||
|
console.error('[tts] Wiedergabe verweigert', e && e.name);
|
||||||
|
gehWeiter();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stimme = deutscheStimme();
|
||||||
|
if (!stimme) {
|
||||||
|
console.error('[tts] Kein Ton vom Hub und keine deutsche Stimme - nur Anzeige.');
|
||||||
|
fehltEl.style.display = 'block';
|
||||||
|
setTimeout(gehWeiter, dauerMs(n.text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rede = new SpeechSynthesisUtterance(n.text);
|
||||||
|
rede.lang = stimme.lang;
|
||||||
|
rede.voice = stimme;
|
||||||
|
rede.rate = tempo;
|
||||||
|
rede.volume = lautstaerke;
|
||||||
rede.onend = gehWeiter;
|
rede.onend = gehWeiter;
|
||||||
rede.onerror = function (e) {
|
rede.onerror = function (e) {
|
||||||
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||||
gehWeiter();
|
gehWeiter();
|
||||||
};
|
};
|
||||||
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
|
aktuell = { stopp: function () { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } } };
|
||||||
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
|
|
||||||
setTimeout(gehWeiter, 5000 + n.text.length * 250);
|
|
||||||
|
|
||||||
window.speechSynthesis.speak(rede);
|
window.speechSynthesis.speak(rede);
|
||||||
}
|
}
|
||||||
|
|
||||||
Overlay.on('tts', function (ev) {
|
Overlay.on('tts', function (ev) {
|
||||||
warteschlange.push({ name: ev.name, text: ev.text });
|
warteschlange.push({ id: ev.id, name: ev.name, text: ev.text, audio: !!ev.audio });
|
||||||
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
||||||
if (!laeuft) naechste();
|
if (!laeuft) naechste();
|
||||||
});
|
});
|
||||||
@@ -143,7 +169,8 @@
|
|||||||
// Panikschalter: sofort still, Warteschlange weg.
|
// Panikschalter: sofort still, Warteschlange weg.
|
||||||
Overlay.on('still', function () {
|
Overlay.on('still', function () {
|
||||||
warteschlange.length = 0;
|
warteschlange.length = 0;
|
||||||
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
|
if (aktuell) aktuell.stopp();
|
||||||
|
aktuell = null;
|
||||||
laeuft = false;
|
laeuft = false;
|
||||||
verstecke();
|
verstecke();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# @abdulive/recap
|
|
||||||
|
|
||||||
Stream-Zusammenfassung nach Streamende als Discord-Post.
|
|
||||||
|
|
||||||
Spezifikation: [docs/projects/stream-recap.md](../../docs/projects/stream-recap.md)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# @abdulive/vod-chapters
|
|
||||||
|
|
||||||
Kapitel-Timestamps aus Kategorie- und Szenenwechseln.
|
|
||||||
|
|
||||||
Spezifikation: [docs/projects/vod-chapters.md](../../docs/projects/vod-chapters.md)
|
|
||||||
+11
-29
@@ -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
|
||||||
@@ -152,6 +127,13 @@ rewards:
|
|||||||
# Satz ist schlimmer als gar keiner.
|
# Satz ist schlimmer als gar keiner.
|
||||||
blockliste: []
|
blockliste: []
|
||||||
proPersonProStunde: 3
|
proPersonProStunde: 3
|
||||||
|
# Gesprochen wird im Hub mit Piper, nicht im Browser - dort haengt die
|
||||||
|
# Stimme am Rechner, der streamt. Name einer Datei unter models/piper/
|
||||||
|
# (ohne .onnx) oder ein absoluter Pfad. Einrichten: scripts/install-piper.sh
|
||||||
|
stimme: de_DE-thorsten-medium
|
||||||
|
# Nur bei Stimmen mit mehreren Sprechern, z. B. de_DE-mls-medium (0-235).
|
||||||
|
# Die klang im Stream allerdings verwaschen.
|
||||||
|
# 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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+242
-29
@@ -1,6 +1,6 @@
|
|||||||
# Übergabe
|
# Übergabe
|
||||||
|
|
||||||
Stand **2026-09-17**, 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 | |
|
||||||
|
|
||||||
193 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
|
||||||
@@ -47,9 +47,10 @@ systemd (`abdulive-hub`). Der Container hat keinen Zugang zum Repo - das
|
|||||||
Skript schiebt den Arbeitsstand als Archiv hinüber.
|
Skript schiebt den Arbeitsstand als Archiv hinüber.
|
||||||
|
|
||||||
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
|
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
|
||||||
meldet beim Start "Rollen-Knöpfe sind scharf". Die Twitch-Broadcaster-App ist
|
meldet beim Start "Rollen-Knöpfe sind scharf". **Abdu hat am 2026-09-23 sein
|
||||||
hinterlegt und geprüft (Client-ID registriert, Secret passt - Twitch stellt
|
Konto verbunden** (`abdulive_`, alle 9 Scopes). Damit laufen Helix mit
|
||||||
darauf ein App-Token aus). **Was fehlt, ist der Klick von Abdu**, siehe unten.
|
Nutzer-Token und EventSub. Der Chat liest weiter anonym mit, weil das
|
||||||
|
Bot-Konto noch nicht verbunden ist.
|
||||||
|
|
||||||
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
|
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
|
||||||
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
|
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
|
||||||
@@ -76,29 +77,185 @@ Der Hub steht auf `mode: draft` - der Post geht vollständig, aber in
|
|||||||
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
|
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
|
||||||
`/var/lib/abdulive/config/config.yaml`.
|
`/var/lib/abdulive/config/config.yaml`.
|
||||||
|
|
||||||
**Ausgelöst hat er noch nie**, und das liegt nicht am Broadcaster, sondern an
|
**Ausgelöst hat er noch nie.** Bis zum 2026-09-23 fehlte das
|
||||||
Twitch: die Erkennung des Stream-Starts hängt am Helix-Polling, und das
|
Broadcaster-Token; das ist jetzt da. Der nächste Stream ist der erste echte
|
||||||
braucht ein Broadcaster-Token. Solange das fehlt, wartet der Ping auf ein
|
Durchlauf, im Entwurfsmodus.
|
||||||
Ereignis, das nie kommt.
|
|
||||||
|
|
||||||
## 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
|
||||||
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
|
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
|
||||||
|
|
||||||
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
|
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
|
||||||
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub.
|
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub. EventSub
|
||||||
|
läuft inzwischen, abonniert ist aber nur `channel_points_custom_reward_redemption.add`.
|
||||||
|
Follows wären ein zweites Abonnement (`channel.follow`, Version 2, braucht
|
||||||
|
`moderator:read:followers`, der Scope ist schon da).
|
||||||
|
|
||||||
|
**Kamerarahmen, Entwurf** (`/kamera`, 520×380 auf 0,0): ein Rahmen rechts und
|
||||||
|
unten um die Facecam, darunter eine Platte mit Kanal, Kategorie, Zuschauern
|
||||||
|
und Laufzeit. Soll die Statusleiste ersetzen, die als unschön empfunden wird.
|
||||||
|
Über der Kamera liegt bewusst nichts, weil genau dieser Bereich für die
|
||||||
|
Shorts ausgeschnitten wird. Noch nicht ausgerollt, Rückmeldung zur Richtung
|
||||||
|
steht aus. Ansehen: `node apps/cli/main.ts overlay --demo`, dann
|
||||||
|
`http://127.0.0.1:4791/kamera?probe=1`.
|
||||||
|
|
||||||
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
|
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
|
||||||
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
||||||
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
|
||||||
|
|
||||||
|
Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub
|
||||||
|
prüft (`apps/hub/src/rewards.ts`), Piper spricht, `/tts` spielt ab.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Belohnung | „Nachricht vorlesen", 1000 Punkte, Texteingabe Pflicht |
|
||||||
|
| Freigabe | keine, wird direkt vorgelesen |
|
||||||
|
| Schutz | Wortfilter (29 Einträge), 200 Zeichen, 3 je Person und Stunde, „Sofort still" auf der Übersichtsseite |
|
||||||
|
| Stimme | Piper, `de_DE-thorsten-medium` (mls klang verwaschen) |
|
||||||
|
|
||||||
|
**Die Stimme entsteht im Hub, nicht im Browser.** Zuerst lief es über
|
||||||
|
`speechSynthesis` in der Browser-Quelle. Das hat in OBS gesprochen, aber
|
||||||
|
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
|
||||||
|
in 0,7 s), das Stimmenmodell unter `models/piper/`. Beides überlebt jedes
|
||||||
|
Ausrollen; eingerichtet mit `scripts/install-piper.sh`.
|
||||||
|
|
||||||
|
**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`).
|
||||||
|
Nur die App, die eine Belohnung angelegt hat, darf deren Einlösungen
|
||||||
|
abschließen oder erstatten. Im Dashboard sieht man davon nichts. Titel,
|
||||||
|
Kosten und Abklingzeit darf man dort ändern; löschen und neu anlegen nicht,
|
||||||
|
dann ist die ID weg, die in der Hub-Konfiguration steht.
|
||||||
|
|
||||||
|
**Der Wortfilter normalisiert vorher**: Leetspeak, Punkte zwischen den
|
||||||
|
Buchstaben, gezogene Buchstaben, Akzente. Einträge ab 5 Buchstaben werden im
|
||||||
|
zusammengezogenen Text gesucht, kürzere nur am Wortanfang, sonst stünde
|
||||||
|
„nazi" in „kann azubi". Die Liste steht nur in der Hub-Konfiguration, nicht
|
||||||
|
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
|
||||||
|
Zeichenketten, jede Belohnung war ohne Meldung unwirksam - auch das Beispiel
|
||||||
|
in `config.example.yaml`. Behoben und getestet; die bestehenden Dateien
|
||||||
|
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
|
||||||
@@ -109,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.
|
||||||
@@ -155,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
|
||||||
@@ -197,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
|
||||||
|
|
||||||
@@ -300,11 +490,11 @@ 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.** Das ist jetzt der Flaschenhals: ohne
|
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
|
||||||
Broadcaster-Token gibt es keine Zuschauerzahlen, keine Stream-Sitzungen und
|
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
|
||||||
damit auch keinen Go-Live-Ping.
|
muss.
|
||||||
|
|
||||||
Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
|
Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
|
||||||
anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
|
anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
|
||||||
@@ -325,15 +515,19 @@ 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.
|
||||||
|
|
||||||
Ohne Punkt 1 liest das Toolkit den Chat anonym mit und kann nichts senden.
|
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
|
||||||
|
Chat anonym mit und kann nichts senden.
|
||||||
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
|
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
|
||||||
|
|
||||||
Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
|
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.
|
||||||
@@ -341,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
|
||||||
|
|
||||||
@@ -349,14 +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.
|
||||||
|
|
||||||
**`apps/recap` bauen.** Der Webhook liegt bereit, der Hub hat die Daten, und
|
**Das Overlay neu machen** (nächste Sitzung, siehe oben). Der Kamerarahmen
|
||||||
anders als S2 hängt daran nichts. Guter Nebenweg, wenn der Stream-PC gerade
|
als Entwurf geht darin auf, die Statusleiste soll ersetzt werden. Danach die
|
||||||
nicht verfügbar ist.
|
Liegestütze-Quelle in OBS eintragen und im Stream einmal beobachten.
|
||||||
|
|
||||||
|
**Recap und die übrigen geplanten Werkzeuge sind gestrichen** (2026-10-27).
|
||||||
|
Raid-Scout, Auswertung, VOD-Kapitel, Community-Gedächtnis, Punkteshop und
|
||||||
|
Recap sind samt Platzhaltern, Spezifikationen und Konfigurationsabschnitten
|
||||||
|
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
|
||||||
@@ -376,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
|
||||||
@@ -390,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
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -60,12 +60,16 @@ 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;
|
||||||
blockliste: string[]; proPersonProStunde: number;
|
blockliste: string[]; proPersonProStunde: number;
|
||||||
|
/** Piper-Stimme: Name im Modellordner (models/piper/<name>.onnx) oder absoluter Pfad. */
|
||||||
|
stimme: string;
|
||||||
|
/** Sprecher-Nummer bei Stimmen mit mehreren Sprechern. Leer: der erste. */
|
||||||
|
sprecher?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
overlay: { port: number; enabled: boolean };
|
overlay: { port: number; enabled: boolean };
|
||||||
@@ -76,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;
|
||||||
@@ -131,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
|
||||||
@@ -142,10 +152,12 @@ const DEFAULTS = {
|
|||||||
maxZeichen: 200,
|
maxZeichen: 200,
|
||||||
blockliste: [] as string[],
|
blockliste: [] as string[],
|
||||||
proPersonProStunde: 3,
|
proPersonProStunde: 3,
|
||||||
|
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 },
|
||||||
};
|
};
|
||||||
@@ -175,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,37 @@ test('ein Byte Order Mark verstellt nicht den ersten Schluessel', () => {
|
|||||||
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
|
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
|
||||||
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
|
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Listen aus Maps, wie in rewards.belohnungen', () => {
|
||||||
|
// Frueher zerfiel das in Zeichenketten wie "id: abc", und die Belohnung
|
||||||
|
// war ohne jede Meldung unwirksam.
|
||||||
|
const y = parseYaml([
|
||||||
|
'rewards:',
|
||||||
|
' enabled: true',
|
||||||
|
' belohnungen:',
|
||||||
|
' - id: abc-123',
|
||||||
|
' titel: Nachricht vorlesen',
|
||||||
|
' aktion: tts',
|
||||||
|
' - titel: Konfetti',
|
||||||
|
' aktion: konfetti',
|
||||||
|
' tts:',
|
||||||
|
' blockliste:',
|
||||||
|
' - sieg heil',
|
||||||
|
' - http',
|
||||||
|
' maxZeichen: 200',
|
||||||
|
].join('\n'));
|
||||||
|
assert.deepEqual(y, {
|
||||||
|
rewards: {
|
||||||
|
enabled: true,
|
||||||
|
belohnungen: [
|
||||||
|
{ id: 'abc-123', titel: 'Nachricht vorlesen', aktion: 'tts' },
|
||||||
|
{ titel: 'Konfetti', aktion: 'konfetti' },
|
||||||
|
],
|
||||||
|
tts: { blockliste: ['sieg heil', 'http'], maxZeichen: 200 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Listeneintrag mit Doppelpunkt ohne Leerzeichen bleibt Text', () => {
|
||||||
|
assert.deepEqual(parseYaml('a:\n - http://x.de\n'), { a: ['http://x.de'] });
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
||||||
//
|
//
|
||||||
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
||||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und
|
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren oder
|
||||||
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
// Maps (`- titel: X`) und Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
||||||
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
|
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
|
||||||
// schreiben, ist ueberschaubar.
|
// schreiben, ist ueberschaubar.
|
||||||
|
|
||||||
@@ -73,7 +73,20 @@ export function parseYaml(text: string): YamlMap {
|
|||||||
if (body.startsWith('- ')) {
|
if (body.startsWith('- ')) {
|
||||||
const key = frame.listKey;
|
const key = frame.listKey;
|
||||||
const list = key ? frame.target[key] : undefined;
|
const list = key ? frame.target[key] : undefined;
|
||||||
if (Array.isArray(list)) list.push(scalar(body.slice(2)));
|
const item = body.slice(2);
|
||||||
|
// `- titel: X` beginnt eine Map als Listeneintrag. Die Zeile wird mit
|
||||||
|
// der Einrueckung ihres Inhalts neu gelesen, die folgenden, tiefer
|
||||||
|
// eingerueckten Zeilen landen in derselben Map. Ohne das zerfiel
|
||||||
|
// `rewards.belohnungen` in einzelne Zeichenketten, und jede Belohnung
|
||||||
|
// war ohne Fehlermeldung unwirksam.
|
||||||
|
if (Array.isArray(list) && /^[^\s'"{[#-][^:]*:(\s|$)/.test(item)) {
|
||||||
|
const map: YamlMap = {};
|
||||||
|
list.push(map);
|
||||||
|
stack.push({ indent, target: map });
|
||||||
|
lines[i] = ' '.repeat(indent + 2) + item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(list)) list.push(scalar(item));
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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**
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Piper selbst laeuft hier nicht - das Programm gibt es nur im Hub. Geprueft
|
||||||
|
// wird, was vorher mit dem fremden Text passiert.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { piperFehlt, sprechen, stimmenPfad, textFuerPiper } from './piper.ts';
|
||||||
|
|
||||||
|
test('Zeilenumbrueche werden zu Leerzeichen, sonst liest Piper zwei Durchlaeufe', () => {
|
||||||
|
assert.equal(textFuerPiper('Hallo\nWelt\r\nwie gehts'), 'Hallo Welt wie gehts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Steuerzeichen fliegen raus, Umlaute und Emoji bleiben', () => {
|
||||||
|
assert.equal(textFuerPiper('Grüße\u0007 aus\u0000 Köln 🎉'), 'Grüße aus Köln 🎉');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leerer Text wird gar nicht erst gesprochen', async () => {
|
||||||
|
await assert.rejects(sprechen(' \n ', { model: '/gibt/es/nicht.onnx' }), /kein Text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlendes Programm meldet sich als Fehler statt als Stille', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
sprechen('Hallo', { binary: '/gibt/es/nicht/piper', model: '/gibt/es/nicht.onnx' }),
|
||||||
|
/Piper nicht startbar/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('piperFehlt nennt Programm und Stimme', () => {
|
||||||
|
const fehlt = piperFehlt({ binary: '/gibt/es/nicht/piper', model: '/gibt/es/nicht.onnx' });
|
||||||
|
assert.equal(fehlt.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stimmenname wird zum Pfad im Modellordner, ein absoluter Pfad bleibt', () => {
|
||||||
|
assert.match(stimmenPfad('de_DE-thorsten-medium'), /piper[\\/]de_DE-thorsten-medium\.onnx$/);
|
||||||
|
const abs = process.platform === 'win32' ? 'C:\\x\\s.onnx' : '/x/s.onnx';
|
||||||
|
assert.equal(stimmenPfad(abs), abs);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Text in Sprache, mit Piper.
|
||||||
|
//
|
||||||
|
// Warum nicht mehr `speechSynthesis` im Browser: in OBS' Browser-Quelle
|
||||||
|
// kam eine amerikanische Stimme heraus, weil der eingebaute Browser keine
|
||||||
|
// deutsche sah. Welche Stimmen es gibt, haengt am Rechner, der streamt - und
|
||||||
|
// damit an etwas, das hier niemand in der Hand hat. Piper laeuft im Hub, die
|
||||||
|
// Stimme liegt als Datei daneben und klingt auf jedem Rechner gleich.
|
||||||
|
//
|
||||||
|
// Gemessen im LXC (2 Kerne): 200 Zeichen in 0,7 s, rund 11 s Audio. Das ist
|
||||||
|
// schnell genug, um es pro Nachricht frisch zu erzeugen.
|
||||||
|
//
|
||||||
|
// Der Text geht ueber stdin hinein, nie als Argument - er ist fremder Text
|
||||||
|
// aus dem Chat, und so kommt er gar nicht erst in die Naehe einer Shell.
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { paths } from '#core/config.ts';
|
||||||
|
import { findBinary } from '#core/platform.ts';
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
|
||||||
|
const log = logger('piper');
|
||||||
|
|
||||||
|
export type PiperOptions = {
|
||||||
|
binary?: string;
|
||||||
|
/** Pfad zur .onnx-Datei. Die .onnx.json muss daneben liegen. */
|
||||||
|
model: string;
|
||||||
|
/**
|
||||||
|
* Nummer des Sprechers bei Modellen mit mehreren (z. B. `mls` mit 236).
|
||||||
|
* Ohne Angabe nimmt Piper den ersten.
|
||||||
|
*/
|
||||||
|
speaker?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pfad zum Stimmenmodell aus seinem Namen, z. B. `de_DE-thorsten-medium`. */
|
||||||
|
export function stimmenPfad(name: string): string {
|
||||||
|
return path.isAbsolute(name) ? name : path.join(paths.models, 'piper', `${name}.onnx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const piperBinary = (): string => findBinary('piper', 'PIPER_BIN');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text so herrichten, dass Piper ihn als einen Satzblock liest.
|
||||||
|
*
|
||||||
|
* Piper liest zeilenweise: jede Zeile waere ein eigener Durchlauf. Steuer-
|
||||||
|
* zeichen haben in gesprochenem Text nichts verloren.
|
||||||
|
*/
|
||||||
|
export function textFuerPiper(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was fehlt, damit gesprochen werden kann. Leer heisst: alles da. */
|
||||||
|
export function piperFehlt(opts: PiperOptions): string[] {
|
||||||
|
const fehlt: string[] = [];
|
||||||
|
const bin = opts.binary ?? piperBinary();
|
||||||
|
// Ein blanker Name wird erst beim Start im PATH gesucht; das laesst sich
|
||||||
|
// hier nicht vorab pruefen, also nur absolute Pfade.
|
||||||
|
if (path.isAbsolute(bin) && !fs.existsSync(bin)) fehlt.push(`Programm ${bin}`);
|
||||||
|
if (!fs.existsSync(opts.model)) fehlt.push(`Stimme ${opts.model}`);
|
||||||
|
else if (!fs.existsSync(`${opts.model}.json`)) fehlt.push(`Stimmen-Einstellungen ${opts.model}.json`);
|
||||||
|
return fehlt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt eine WAV-Datei im Speicher. */
|
||||||
|
export function sprechen(text: string, opts: PiperOptions): Promise<Buffer> {
|
||||||
|
const eingabe = textFuerPiper(text);
|
||||||
|
if (!eingabe) return Promise.reject(new Error('kein Text'));
|
||||||
|
const bin = opts.binary ?? piperBinary();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// `--output_file -` schreibt die fertige WAV samt Kopf nach stdout; eine
|
||||||
|
// Zwischendatei braucht es damit nicht.
|
||||||
|
const args = ['--model', opts.model, '--output_file', '-', '--quiet'];
|
||||||
|
if (opts.speaker !== undefined) args.push('--speaker', String(opts.speaker));
|
||||||
|
const child = spawn(bin, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const teile: Buffer[] = [];
|
||||||
|
let fehler = '';
|
||||||
|
let fertig = false;
|
||||||
|
const ende = (fn: () => void): void => {
|
||||||
|
if (fertig) return;
|
||||||
|
fertig = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => ende(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error('Piper hat das Zeitlimit ueberschritten'));
|
||||||
|
}), opts.timeoutMs ?? 20_000);
|
||||||
|
|
||||||
|
child.stdout.on('data', (d: Buffer) => teile.push(d));
|
||||||
|
child.stderr.on('data', (d: Buffer) => { fehler = (fehler + d.toString()).slice(-2000); });
|
||||||
|
child.on('error', (err) => ende(() => reject(new Error(`Piper nicht startbar (${bin}): ${err.message}`))));
|
||||||
|
child.on('close', (code) => ende(() => {
|
||||||
|
const wav = Buffer.concat(teile);
|
||||||
|
if (code !== 0 || wav.length < 44) {
|
||||||
|
reject(new Error(`Piper beendet mit Code ${code}: ${fehler.trim().split('\n').pop() ?? ''}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug(`${eingabe.length} Zeichen -> ${Math.round(wav.length / 1024)} KB`);
|
||||||
|
resolve(wav);
|
||||||
|
}));
|
||||||
|
|
||||||
|
child.stdin.on('error', () => { /* Prozess schon weg, das meldet 'close' */ });
|
||||||
|
child.stdin.end(eingabe + '\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }]), []);
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,8 +47,30 @@ 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 = {
|
||||||
|
id: string; title: string; cost: number; prompt: string;
|
||||||
|
is_enabled: boolean; is_user_input_required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was beim Anlegen einer Belohnung mitgeht. Namen wie in der Helix-API. */
|
||||||
|
export type NeueBelohnung = {
|
||||||
|
title: string;
|
||||||
|
cost: number;
|
||||||
|
prompt?: string;
|
||||||
|
is_user_input_required?: boolean;
|
||||||
|
is_global_cooldown_enabled?: boolean;
|
||||||
|
global_cooldown_seconds?: number;
|
||||||
|
/** Die Einloesung sofort als erledigt markieren. Aus, damit wir erstatten koennen. */
|
||||||
|
should_redemptions_skip_request_queue?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
|
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
|
||||||
*
|
*
|
||||||
@@ -176,11 +198,45 @@ export const helix = {
|
|||||||
{ method: 'PATCH', body: JSON.stringify({ status }) });
|
{ method: 'PATCH', body: JSON.stringify({ status }) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kanalpunkt-Belohnungen des Kanals. Mit `nurEigene` nur die, die diese
|
||||||
|
* App angelegt hat - und nur deren Einloesungen darf sie spaeter aendern,
|
||||||
|
* also auch erstatten. Eine im Dashboard angelegte Belohnung taugt dafuer
|
||||||
|
* nicht.
|
||||||
|
*/
|
||||||
|
async customRewards(broadcasterId: string, nurEigene = false): Promise<HelixReward[]> {
|
||||||
|
const r = await call<{ data: HelixReward[] }>('broadcaster',
|
||||||
|
`/channel_points/custom_rewards?broadcaster_id=${broadcasterId}` +
|
||||||
|
(nurEigene ? '&only_manageable_rewards=true' : ''));
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCustomReward(broadcasterId: string, reward: NeueBelohnung): Promise<HelixReward> {
|
||||||
|
const r = await call<{ data: HelixReward[] }>('broadcaster',
|
||||||
|
`/channel_points/custom_rewards?broadcaster_id=${broadcasterId}`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(reward) });
|
||||||
|
const angelegt = r.data[0];
|
||||||
|
if (!angelegt) throw new Error('Twitch hat keine Belohnung zurueckgegeben');
|
||||||
|
return angelegt;
|
||||||
|
},
|
||||||
|
|
||||||
/** Hat das verbundene Konto diesen Scope freigegeben? */
|
/** Hat das verbundene Konto diesen Scope freigegeben? */
|
||||||
hasScope(scope: string): boolean {
|
hasScope(scope: string): boolean {
|
||||||
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' });
|
||||||
|
|||||||
@@ -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' }]);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Piper und die deutsche Stimme in den Hub-LXC bringen.
|
||||||
|
#
|
||||||
|
# Einmalig noetig, danach nur wieder fuer eine andere Stimme. Das Deploy-
|
||||||
|
# Skript fasst beides nicht an: Piper liegt unter /opt/piper, die Stimme im
|
||||||
|
# Modellordner, und beides ueberlebt jedes Ausrollen.
|
||||||
|
#
|
||||||
|
# Piper kommt als fertiges Paket mit eigenem espeak-ng und onnxruntime - im
|
||||||
|
# Container muss nichts aus apt dazu. Die Version ist festgenagelt und am
|
||||||
|
# 2026-09-23 im LXC geprueft: 200 Zeichen in 0,7 s.
|
||||||
|
#
|
||||||
|
# Aufruf: scripts/install-piper.sh
|
||||||
|
# Umgebung (mit Vorgaben):
|
||||||
|
# PVE_HOST=192.168.2.20 Proxmox-Host
|
||||||
|
# CT=141 Container-ID
|
||||||
|
# STIMME=de_DE-thorsten-medium Name aus huggingface.co/rhasspy/piper-voices
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PVE_HOST="${PVE_HOST:-192.168.2.20}"
|
||||||
|
CT="${CT:-141}"
|
||||||
|
STIMME="${STIMME:-de_DE-thorsten-medium}"
|
||||||
|
|
||||||
|
# de_DE-thorsten-medium -> de/de_DE/thorsten/medium
|
||||||
|
IFS=- read -r LOCALE SPRECHER QUALI <<< "$STIMME"
|
||||||
|
SPRACHE="${LOCALE%%_*}"
|
||||||
|
VOICES="https://huggingface.co/rhasspy/piper-voices/resolve/main/${SPRACHE}/${LOCALE}/${SPRECHER}/${QUALI}"
|
||||||
|
PIPER="https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz"
|
||||||
|
|
||||||
|
ssh "root@${PVE_HOST}" "pct exec ${CT} -- bash -lc '
|
||||||
|
set -e
|
||||||
|
if [ ! -x /opt/piper/piper ]; then
|
||||||
|
echo \"Lade Piper ...\"
|
||||||
|
curl -fsSL -o /tmp/piper.tgz ${PIPER}
|
||||||
|
tar xzf /tmp/piper.tgz -C /opt --no-same-owner
|
||||||
|
rm -f /tmp/piper.tgz
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/abdulive/models/piper
|
||||||
|
cd /var/lib/abdulive/models/piper
|
||||||
|
if [ ! -f ${STIMME}.onnx ]; then
|
||||||
|
echo \"Lade Stimme ${STIMME} ...\"
|
||||||
|
curl -fsSL -o ${STIMME}.onnx.json ${VOICES}/${STIMME}.onnx.json
|
||||||
|
curl -fsSL -o ${STIMME}.onnx.part ${VOICES}/${STIMME}.onnx
|
||||||
|
mv ${STIMME}.onnx.part ${STIMME}.onnx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Dienst findet Piper ueber PIPER_BIN. Ein Drop-in statt einer
|
||||||
|
# Aenderung an der Unit, damit ein neues Ausrollen der Unit das nicht
|
||||||
|
# ueberschreibt.
|
||||||
|
mkdir -p /etc/systemd/system/abdulive-hub.service.d
|
||||||
|
printf \"[Service]\nEnvironment=PIPER_BIN=/opt/piper/piper\n\" \
|
||||||
|
> /etc/systemd/system/abdulive-hub.service.d/piper.conf
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo \"Probe ...\"
|
||||||
|
echo \"Hallo, hier spricht der Hub.\" \
|
||||||
|
| /opt/piper/piper --model ${STIMME}.onnx --output_file - --quiet 2>/dev/null \
|
||||||
|
| head -c 4 | grep -q RIFF && echo \"Piper spricht.\"
|
||||||
|
'"
|
||||||
|
|
||||||
|
echo "Fertig. Wirksam nach dem naechsten Neustart des Hubs (scripts/deploy-hub.sh)."
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user