Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s

- /pult als installierbare Web-App (Manifest, Service Worker, Symbole)
- Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen
  ueber die neue Browser-Source /sounds
- Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem
  Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS
- Browser-Sources melden, welche Seite sie sind

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-26 11:51:48 +01:00
1 parent 70448967f5
commit 8be195d938
29 files changed
+2935 -28

No files matched your search

+50
View File
@@ -690,6 +690,51 @@ async function webPasswort(args: string[]): Promise<void> {
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`); console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
} }
/**
* Den Stream-PC beim Hub anmelden. Prueft vorher, ob der Hub den Schluessel
* annimmt - ein Tippfehler soll hier auffallen und nicht erst im Stream, wenn
* der Szenenknopf am Handy nichts tut.
*/
async function fernVerbinden(args: string[]): Promise<void> {
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
const [adresse, schluessel] = args;
if (adresse === 'aus') {
updateSecrets((s) => { delete s.fern; });
console.log('Fernsteuerung getrennt. Wirkt nach dem naechsten Start von abdulive ingest.');
return;
}
if (!adresse || !schluessel) {
const da = readSecrets().fern;
console.log(da ? `Eingerichtet fuer ${da.url}.` : 'Nicht eingerichtet.');
console.log('Aufruf: abdulive fern:verbinden <adresse> <schluessel> (steht auf der Uebersichtsseite)');
return;
}
const url = adresse.replace(/\/+$/, '');
if (!/^https?:\/\//.test(url)) throw new Error('Die Adresse muss mit https:// anfangen.');
const probe = new AbortController();
let status: number;
try {
const res = await fetch(`${url}/fern/befehle`, {
headers: { authorization: `Bearer ${schluessel}` },
signal: AbortSignal.any([probe.signal, AbortSignal.timeout(10_000)]),
});
status = res.status;
} catch (err) {
throw new Error(`Hub unter ${url} nicht erreichbar: ${(err as Error).message}`);
} finally {
// Die Leitung nur oeffnen, um den Schluessel zu pruefen. Offen gelassen
// haette sie der Hub als verbundenen PC gezaehlt.
probe.abort();
}
if (status === 403) throw new Error('Der Hub lehnt den Schluessel ab. Neu von der Uebersichtsseite kopieren.');
if (status !== 200) throw new Error(`Der Hub antwortet ${status}. Laeuft dort eine Fassung mit Pult?`);
updateSecrets((s) => { s.fern = { url, key: schluessel }; });
console.log(`${C.ok}Verbunden.${C.off} Der Hub nimmt den Schluessel an.`);
console.log('Jetzt abdulive ingest neu starten - danach steuert das Pult OBS.');
}
// ---------------------------------------------------------------- golive // ---------------------------------------------------------------- golive
/** /**
@@ -901,6 +946,10 @@ abdulive - Werkzeugkasten fuer Abdus Stream
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern) ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
web:passwort [wort] Passwort fuer die Weboberflaeche setzen web:passwort [wort] Passwort fuer die Weboberflaeche setzen
fern:verbinden <adresse> <schluessel>
Stream-PC beim Hub anmelden, damit das Pult OBS
steuern kann. Der Befehl steht fertig auf der
Uebersichtsseite des Hubs. "aus" statt Adresse trennt.
check-env Umgebung pruefen check-env Umgebung pruefen
db:migrate Migrationen anwenden db:migrate Migrationen anwenden
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
@@ -956,6 +1005,7 @@ try {
} }
case 'overlay': await overlayOnly(args); break; case 'overlay': await overlayOnly(args); break;
case 'web:passwort': await webPasswort(args); break; case 'web:passwort': await webPasswort(args); break;
case 'fern:verbinden': await fernVerbinden(args); break;
case 'discord:bot': await discordBot(); break; case 'discord:bot': await discordBot(); break;
case 'help': case undefined: help(); break; case 'help': case undefined: help(); break;
default: default:
+69
View File
@@ -0,0 +1,69 @@
// 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, sseZerleger, standLesen } 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']);
});
+247
View File
@@ -0,0 +1,247 @@
// 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. Eingerichtet wird mit
// `abdulive fern:verbinden <adresse> <schluessel>`; beides steht fertig auf
// der Uebersichtsseite des Hubs.
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;
/** 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 };
/**
* 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);
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() };
}
}
export type FernDeps = {
url: string;
key: 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(/\/+$/, '');
const kopf = { authorization: `Bearer ${deps.key}` };
let verbunden = false;
let sammler: NodeJS.Timeout | null = null;
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);
};
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 {
const res = await fetch(`${basis}/fern/befehle`, {
headers: { ...kopf, accept: 'text/event-stream' }, signal: 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 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(); },
obsWechsel: spaeterSenden,
verbunden: () => verbunden,
};
}
+19 -2
View File
@@ -19,6 +19,7 @@ import { readSecrets } from '#twitch/secrets.ts';
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 +63,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 +116,20 @@ export async function runIngest(): Promise<void> {
onRawReady: (job) => renderer.enqueue(job), onRawReady: (job) => renderer.enqueue(job),
}); });
// ---- Fernsteuerung vom Pult (Szenen, Clip, Ton). Nur, wenn mit
// `abdulive fern:verbinden` eingerichtet - sonst laeuft alles wie vorher.
const fernZugang = readSecrets().fern;
const fern = fernZugang?.url && fernZugang.key
? createFernanschluss({
url: fernZugang.url,
key: fernZugang.key,
obs,
clip: () => clipper.trigger('manual', { login: 'Pult' }),
})
: null;
if (fern) void fern.run(abort.signal);
else log.info('Pult-Fernsteuerung nicht eingerichtet (abdulive fern:verbinden)');
// ---- Go-Live-Ankuendigung auf Discord // ---- Go-Live-Ankuendigung auf Discord
const announcer = createAnnouncer({ const announcer = createAnnouncer({
cfg: { cfg: {
@@ -138,6 +154,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
+6
View File
@@ -10,8 +10,14 @@ offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse.
/callback OAuth-Rückruf von Twitch offen, `state` schützt /callback OAuth-Rückruf von Twitch offen, `state` schützt
/alerts … die Browser-Sources Schlüssel in der URL /alerts … die Browser-Sources Schlüssel in der URL
/events der Ereignisstrom dahinter Schlüssel in der URL /events der Ereignisstrom dahinter Schlüssel in der URL
/pult Steuerpult fürs Handy (PWA) Passwort
/soundboard/… Sounds hochladen, ändern, spielen Passwort (Datei: Schlüssel)
/fern/… Leitung zum Stream-PC für OBS eigener Schlüssel im Kopf
``` ```
Der Fern-Schlüssel ist der dritte Zugang und der einzige, der etwas schaltet:
er steht deshalb nie in einer Adresse, sondern in `authorization: Bearer …`.
## Server-Sent Events statt WebSocket ## Server-Sent Events statt WebSocket
[`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen [`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen
+11
View File
@@ -66,3 +66,14 @@ test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => {
// beenden lässt - dafür gibt es den Passwortwechsel. // beenden lässt - dafür gibt es den Passwortwechsel.
assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN)); assert.equal(sitzungToken(KEY, HASH, IN_30_TAGEN), sitzungToken(KEY, HASH, IN_30_TAGEN));
}); });
test('der Schluessel des Stream-PCs kommt aus dem Bearer-Kopf', async () => {
// Hier fehlten beim ersten Wurf die Backslashes in der Regex - jeder
// richtige Schluessel wurde abgelehnt, und nichts im Log sah danach aus.
const { bearer } = await import('./auth.ts');
assert.equal(bearer('Bearer 33qg61wQf_ad-x9v'), '33qg61wQf_ad-x9v');
assert.equal(bearer('Bearer abc'), 'abc');
assert.equal(bearer('Basic abc'), null);
assert.equal(bearer('Bearer'), null);
assert.equal(bearer(undefined), null);
});
+29
View File
@@ -35,6 +35,10 @@ 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;
/** 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 +92,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 +123,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 +178,18 @@ 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,
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 }; });
+37 -1
View File
@@ -78,6 +78,25 @@ export type LiegestuetzeEvent = {
name?: string; 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
@@ -103,12 +122,24 @@ export type OverlayEvent =
| { kind: 'still' } | { kind: 'still' }
| LiegestuetzeEvent | 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' }
| 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, damit der Zaehler nach dem Neuverbinden stimmt. */
liegestuetze: { offen: number; gemacht: number }; liegestuetze: { offen: number; gemacht: number };
obs: ObsStandEvent;
}; };
/** /**
@@ -137,6 +168,9 @@ export function createBus(): Bus {
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 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) {
@@ -152,6 +186,8 @@ export function createBus(): Bus {
stream = ev; stream = ev;
} else if (ev.kind === 'liegestuetze') { } else if (ev.kind === 'liegestuetze') {
liegestuetze = { offen: ev.offen, gemacht: ev.gemacht }; 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.
@@ -174,7 +210,7 @@ export function createBus(): Bus {
return () => { listeners.delete(fn); }; return () => { listeners.delete(fn); };
}, },
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }), snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze, obs }),
clients: () => listeners.size, clients: () => listeners.size,
}; };
} }
+116
View File
@@ -0,0 +1,116 @@
// 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), '/');
});
+155
View File
@@ -0,0 +1,155 @@
// 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 };
export type Fernsteuerung = {
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => 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 function createFernsteuerung(bus: Bus): Fernsteuerung {
let leitung: http.ServerResponse | null = null;
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
bus.publish({ kind: 'obs', ...stand });
};
return {
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 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,
};
}
+280 -19
View File
@@ -8,6 +8,8 @@
// /callback OAuth-Rueckruf von Twitch (offen, state schuetzt) // /callback OAuth-Rueckruf von Twitch (offen, state schuetzt)
// /alerts ... die Browser-Sources (Schluessel) // /alerts ... die Browser-Sources (Schluessel)
// /events der SSE-Strom dahinter (Schluessel) // /events der SSE-Strom dahinter (Schluessel)
// /pult Steuerpult fuers Handy (PWA) (Passwort)
// /fern/... Leitung zum Stream-PC fuer OBS (eigener Schluessel)
// //
// Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit // Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit
// der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit // der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit
@@ -18,11 +20,14 @@ import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { paths } from '#core/config.ts';
import { logger } from '#core/logger.ts'; import { logger } from '#core/logger.ts';
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts'; import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
import { createZugang, type Zugang } from './auth.ts'; import { createZugang, type Zugang } from './auth.ts';
import { createBus, type Bus, type OverlayEvent } from './bus.ts'; import { createBus, type Bus, type OverlayEvent } from './bus.ts';
import { startDemo } from './demo.ts'; import { startDemo } from './demo.ts';
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
import { createSoundboard, MAX_BYTES, nameAusDatei, type Soundboard } from './sounds.ts';
import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts'; import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts';
const log = logger('overlay'); const log = logger('overlay');
@@ -46,6 +51,7 @@ const TYPES: Record<string, string> = {
'.png': 'image/png', '.png': 'image/png',
'.jpg': 'image/jpeg', '.jpg': 'image/jpeg',
'.woff2': 'font/woff2', '.woff2': 'font/woff2',
'.webmanifest': 'application/manifest+json; charset=utf-8',
}; };
/** Die Seiten, die als Browser-Source in OBS eingetragen werden. */ /** Die Seiten, die als Browser-Source in OBS eingetragen werden. */
@@ -64,6 +70,8 @@ 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, { 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.' }, 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;
@@ -73,6 +81,7 @@ 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;
}; };
@@ -83,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;
/** /**
@@ -116,13 +127,18 @@ 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')),
fern: createFernsteuerung(bus),
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');
@@ -143,23 +159,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;
} }
@@ -177,7 +205,25 @@ 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)) 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.
// Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der // Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
@@ -186,13 +232,27 @@ async function handle(
const page = req.method === 'GET' || req.method === 'HEAD' const page = req.method === 'GET' || req.method === 'HEAD'
? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined; ? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1]; const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId) { const soundId = /^\/soundboard\/datei\/([a-f0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId || soundId) {
if (!zugang.darfOverlay(req, url)) { if (!zugang.darfOverlay(req, url)) {
return send(res, 403, 'text/plain; charset=utf-8', return send(res, 403, 'text/plain; charset=utf-8',
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' + 'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
'Sie steht fertig auf der Uebersichtsseite.'); 'Sie steht fertig auf der Uebersichtsseite.');
} }
if (p === '/events') return sse(req, res, bus); if (p === '/events') return sse(req, res, bus, url, lz.quellen);
if (soundId) {
const d = sounds.datei(soundId);
if (!d) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
res.writeHead(200, {
'content-type': d.typ, 'content-length': d.daten.length,
// Darf zwischengespeichert werden: eine ID gehoert immer zu derselben
// Datei, und beim zweiten Druck auf denselben Knopf soll es sofort knallen.
'cache-control': 'private, max-age=86400, immutable',
'x-content-type-options': 'nosniff',
});
res.end(d.daten);
return;
}
if (audioId) { if (audioId) {
const wav = opts.tts?.audio(audioId); const wav = opts.tts?.audio(audioId);
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.'); if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
@@ -207,10 +267,39 @@ async function handle(
// ---- Ab hier nur mit Anmeldung. // ---- Ab hier nur mit Anmeldung.
if (!zugang.istAngemeldet(req)) { if (!zugang.istAngemeldet(req)) {
// Fragen von Skripten (fetch) bekommen eine kurze Antwort statt der
// 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', return send(res, 401, 'text/html; charset=utf-8',
loginPage(opts.channel, url.searchParams.has('fehler'))); 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/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.
if (p === '/test' && req.method === 'POST') { if (p === '/test' && req.method === 'POST') {
@@ -323,10 +412,15 @@ async function handle(
zugang.neuerOverlayKey(); zugang.neuerOverlayKey();
return redirect(res, '/'); return redirect(res, '/');
} }
if (p === '/fernschluessel/neu' && req.method === 'POST') {
zugang.neuerFernKey();
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;
@@ -336,7 +430,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',
@@ -366,7 +469,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);
} }
@@ -378,8 +489,119 @@ 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' : '/';
}
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[] = [];
@@ -486,13 +708,19 @@ const SEITEN_CSS = `
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">
@@ -507,7 +735,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.
@@ -571,9 +800,27 @@ für Korrekturen und zählt nicht. Die Zuschauer sehen jede Änderung.</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>
@@ -607,6 +854,20 @@ ${ttsFeld}
${lzFeld} ${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. Einmal auf dem Stream-PC in PowerShell
ausführen, danach <code>abdulive ingest</code> neu starten:</p>
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
<button data-copy="fernbefehl">kopieren</button></p>
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
<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>
+89
View File
@@ -0,0 +1,89 @@
// Der Sound-Speicher nimmt Dateien vom Handy an und liefert sie unter der
// eigenen Adresse wieder aus. Hier wird vor allem geprueft, dass er nur Ton
// annimmt - und dass die Liste einen Neustart uebersteht.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createSoundboard, erkenneTyp, nameAusDatei, MAX_BYTES } from './sounds.ts';
const ordner = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-sounds-'));
const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.alloc(64, 1)]);
const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(32)]);
const M4A = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypM4A '), Buffer.alloc(32)]);
test('Tontypen werden an den ersten Bytes erkannt', () => {
assert.equal(erkenneTyp(MP3), 'audio/mpeg');
assert.equal(erkenneTyp(Buffer.from([0xff, 0xfb, 0x90, 0x64, 0, 0, 0, 0, 0, 0, 0, 0])), 'audio/mpeg');
assert.equal(erkenneTyp(WAV), 'audio/wav');
assert.equal(erkenneTyp(Buffer.concat([Buffer.from('OggS'), Buffer.alloc(20)])), 'audio/ogg');
assert.equal(erkenneTyp(M4A), 'audio/mp4');
});
test('HTML ist kein Ton, auch wenn der Browser es behauptet', () => {
// Sonst waere das eine Ablage fuer beliebige Seiten unter der eigenen Adresse.
assert.equal(erkenneTyp(Buffer.from('<!doctype html><script>alert(1)</script>')), null);
assert.equal(erkenneTyp(Buffer.from('kurz')), null);
const sb = createSoundboard(ordner());
assert.throws(() => sb.hinzufuegen('x', Buffer.from('<html><body>hallo welt</body></html>')), /Kein Ton/);
assert.equal(sb.liste().length, 0);
});
test('zu gross wird abgelehnt, bevor etwas auf der Platte landet', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const riesig = Buffer.concat([MP3, Buffer.alloc(MAX_BYTES)]);
assert.throws(() => sb.hinzufuegen('lied', riesig), /Zu gross/);
assert.deepEqual(fs.readdirSync(dir), []);
});
test('hochladen, abholen, nach Neustart noch da', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const s = sb.hinzufuegen('Tusch', MP3);
assert.equal(s.typ, 'audio/mpeg');
assert.ok(s.datei.endsWith('.mp3'));
assert.deepEqual(sb.datei(s.id)?.daten, MP3);
const nachNeustart = createSoundboard(dir);
assert.equal(nachNeustart.liste()[0]?.name, 'Tusch');
assert.deepEqual(nachNeustart.datei(s.id)?.daten, MP3);
});
test('aendern nimmt nur, was erlaubt ist', () => {
const sb = createSoundboard(ordner());
const s = sb.hinzufuegen('Tusch', MP3);
const neu = sb.aendern(s.id, {
name: ' Ba\ndum tss ', emoji: '🥁', farbe: 'url(javascript:x)', laut: 3,
})!;
assert.equal(neu.name, 'Ba dum tss');
assert.equal(neu.emoji, '🥁');
assert.equal(neu.farbe, s.farbe, 'freie Farbwerte waeren CSS-Einschleusung');
assert.equal(neu.laut, 1);
assert.equal(sb.aendern(s.id, { name: ' ' })?.name, 'Ba dum tss', 'leerer Name behaelt den alten');
});
test('verschieben und loeschen', () => {
const dir = ordner();
const sb = createSoundboard(dir);
const a = sb.hinzufuegen('A', MP3);
const b = sb.hinzufuegen('B', WAV);
sb.hinzufuegen('C', M4A);
assert.equal(sb.verschieben(b.id, -1), true);
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'A', 'C']);
assert.equal(sb.verschieben(b.id, -1), false, 'vor dem ersten ist nichts');
assert.equal(sb.entfernen(a.id), true);
assert.deepEqual(sb.liste().map((s) => s.name), ['B', 'C']);
assert.equal(fs.existsSync(path.join(dir, a.datei)), false, 'die Datei geht mit');
assert.equal(sb.entfernen(a.id), false);
});
test('Name aus dem Dateinamen', () => {
assert.equal(nameAusDatei('ba_dum_tss.mp3'), 'ba dum tss');
assert.equal(nameAusDatei('.mp3'), 'Sound');
assert.equal(nameAusDatei('Aufnahme 12.m4a'), 'Aufnahme 12');
});
+187
View File
@@ -0,0 +1,187 @@
// Die Sounds fuer das Pult.
//
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
// /sounds. Dazwischen liegt nur dieser Ordner:
//
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
// <db>/sounds/<id>.<endung> die Datei selbst
//
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
//
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export type Sound = {
id: string;
name: string;
emoji: string;
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
farbe: string;
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
laut: number;
datei: string;
typ: string;
bytes: number;
seit: string;
};
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
export const MAX_BYTES = 8 * 1024 * 1024;
export const MAX_SOUNDS = 120;
const ENDUNG: Record<string, string> = {
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
'audio/mp4': '.m4a',
};
/**
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
* Browser sicher abspielt.
*/
export function erkenneTyp(b: Buffer): string | null {
if (b.length < 12) return null;
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
return null;
}
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
export function nameAusDatei(datei: string): string {
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
return saeubern(ohne) || 'Sound';
}
function saeubern(s: string, max = 40): string {
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
.slice(0, max).join('');
}
function emojiOk(s: string): string {
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
}
export type Soundboard = {
liste: () => Sound[];
holen: (id: string) => Sound | undefined;
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
hinzufuegen: (name: string, daten: Buffer) => Sound;
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
entfernen: (id: string) => boolean;
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
verschieben: (id: string, um: number) => boolean;
};
export function createSoundboard(ordner: string): Soundboard {
const index = path.join(ordner, 'sounds.json');
const sounds: Sound[] = lesen();
function lesen(): Sound[] {
try {
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
return Array.isArray(roh.sounds) ? roh.sounds : [];
} catch {
return [];
}
}
function schreiben(): void {
fs.mkdirSync(ordner, { recursive: true });
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
// bleibt die alte Liste ganz statt einer halben neuen.
const tmp = `${index}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
fs.renameSync(tmp, index);
}
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
return {
liste: () => sounds.map((s) => ({ ...s })),
holen,
datei(id) {
const s = holen(id);
if (!s) return undefined;
try {
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
} catch {
return undefined;
}
},
hinzufuegen(name, daten) {
if (daten.length > MAX_BYTES) {
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
}
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
const typ = erkenneTyp(daten);
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
const id = crypto.randomBytes(6).toString('hex');
const datei = `${id}${ENDUNG[typ]}`;
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(path.join(ordner, datei), daten);
const s: Sound = {
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
};
sounds.push(s);
schreiben();
return { ...s };
},
aendern(id, was) {
const s = holen(id);
if (!s) return undefined;
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
if (was.laut !== undefined && Number.isFinite(was.laut)) {
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
}
schreiben();
return { ...s };
},
entfernen(id) {
const i = sounds.findIndex((s) => s.id === id);
if (i === -1) return false;
const [weg] = sounds.splice(i, 1);
schreiben();
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
return true;
},
verschieben(id, um) {
const i = sounds.findIndex((s) => s.id === id);
const ziel = i + Math.sign(um);
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
schreiben();
return true;
},
};
}
+44 -2
View File
@@ -17,6 +17,7 @@ und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes).
| `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. | | `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. |
| `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. | | `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. |
| `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. | | `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. |
| `/sounds` | 400×120 | Spielt die Sounds vom Pult. Unsichtbar, **mit Ton**. |
Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`). Die fertigen Adressen mit Schlüssel stehen auf der Übersichtsseite (`/`).
@@ -81,8 +82,49 @@ 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“. |
| Test | Dieselben Testknöpfe wie auf der Übersicht. |
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`). Einmal auf dem Stream-PC:
```
abdulive fern:verbinden https://abdu.dimension47.de <schlüssel>
```
Der fertige Befehl steht auf der Übersicht unter „Stream-PC verbinden“.
Danach `abdulive ingest` neu starten. Jeder Knopf wartet auf die Antwort von
OBS und sagt ehrlich, wenn es nicht ging.
Die App-Symbole zeichnet `node scripts/make-icons.ts`.
## Vorlesen (`/tts`) ## Vorlesen (`/tts`)
+5 -1
View File
@@ -27,7 +27,11 @@
function verbinde() { function verbinde() {
if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } } if (quelle) { try { quelle.close(); } catch (e) { /* schon zu */ } }
var url = '/events' + (params.get('key') ? '?key=' + encodeURIComponent(params.get('key')) : ''); // `quelle` sagt dem Server, welche Seite zuhoert. Er zaehlt damit nur -
// das Pult will wissen, ob die Sound-Quelle in OBS ueberhaupt offen ist.
var quelleName = location.pathname.replace(/^\/+|\.html$/g, '') || 'index';
var url = '/events?quelle=' + encodeURIComponent(quelleName) +
(params.get('key') ? '&key=' + encodeURIComponent(params.get('key')) : '');
quelle = new EventSource(url); quelle = new EventSource(url);
letzteNachricht = Date.now(); letzteNachricht = Date.now();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -0,0 +1,21 @@
{
"name": "abdulive Pult",
"short_name": "Pult",
"description": "Soundboard, Szenen, 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" }] }
]
}
+408
View File
@@ -0,0 +1,408 @@
/* 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; }
.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; }
/* ------------------------------------------------------------ Reiter */
.reiter {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: grid; grid-template-columns: repeat(4, 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; }
}
+195
View File
@@ -0,0 +1,195 @@
<!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="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>
</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>
</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
</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-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>
+638
View File
@@ -0,0 +1,638 @@
/* Das Pult: Soundboard, Szenen, Liegestuetze, Vorlesen - am Handy.
*
* 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); };
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) {
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();
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 = 'Auf dem Stream-PC muss abdulive ingest laufen und einmal mit ' +
'„abdulive fern:verbinden …“ eingerichtet sein. Der Befehl steht auf der Übersicht unter „Stream-PC verbinden“.';
} 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' });
});
// ------------------------------------------------------------ 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'); });
});
// ------------------------------------------------------------ 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);
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;
}
};
}
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();
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 */ } }
if (gemerkt && $('tab-' + gemerkt)) zeigeReiter(gemerkt);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function (e) {
console.warn('[pult] Service Worker nicht registriert', e);
});
}
})();
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Soundboard</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Eine reine Tonquelle. Im Stream ist nichts zu sehen - ein Sound, der
* zusaetzlich seinen Namen ins Bild schreibt, nimmt ihm die Pointe.
*
* Mit ?probe=1 steht unten, was gerade klingt. Damit laesst sich im Browser
* pruefen, ob ein Druck am Handy hier ankommt, auch ohne Lautsprecher. */
#liste {
position: fixed;
left: 12px; right: 12px; bottom: 12px;
display: none;
flex-direction: column;
gap: 6px;
font: 600 16px/1.2 var(--font-zahl);
}
body.probe #liste { display: flex; }
.laeuft {
padding: 8px 12px;
border-radius: 10px;
background: var(--tinte-klar);
border-left: 4px solid var(--lila);
}
.laeuft.fehler { border-left-color: var(--rot); }
</style>
</head>
<body>
<div id="liste"></div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var liste = document.getElementById('liste');
var schluessel = Overlay.param('key', '');
/* Lautstaerke der ganzen Quelle. Besser im OBS-Mixer regeln; das hier ist
* fuer den Fall, dass alle Sounds gemeinsam zu laut aufgenommen sind. */
var gesamt = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
/* Mehr gleichzeitig klingt nicht mehr nach Soundboard, sondern nach Unfall.
* Der aelteste Sound macht dem neuen Platz. */
var maxGleichzeitig = Math.max(1, Overlay.param('max', 4));
var laufend = [];
function zeile(name, fehler) {
var d = document.createElement('div');
d.className = 'laeuft' + (fehler ? ' fehler' : '');
d.textContent = name;
liste.appendChild(d);
return d;
}
function aufhoeren(eintrag) {
var i = laufend.indexOf(eintrag);
if (i !== -1) laufend.splice(i, 1);
try { eintrag.ton.pause(); } catch (e) { /* egal */ }
eintrag.ton.removeAttribute('src');
if (eintrag.zeile.parentNode) eintrag.zeile.parentNode.removeChild(eintrag.zeile);
}
function alleStopp() {
laufend.slice().forEach(aufhoeren);
}
Overlay.on('sound', function (ev) {
while (laufend.length >= maxGleichzeitig) aufhoeren(laufend[0]);
var ton = new Audio('/soundboard/datei/' + encodeURIComponent(ev.id) +
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
var laut = typeof ev.laut === 'number' ? ev.laut : 0.8;
ton.volume = Math.max(0, Math.min(1, laut * gesamt));
var eintrag = { ton: ton, zeile: zeile(ev.name || 'Sound') };
laufend.push(eintrag);
ton.onended = function () { aufhoeren(eintrag); };
ton.onerror = function () {
console.error('[sounds] nicht abspielbar', ev.name, ton.error && ton.error.code);
eintrag.zeile.classList.add('fehler');
setTimeout(function () { aufhoeren(eintrag); }, 3000);
};
// Notbremse: ein Sound, der nie "zu Ende" meldet, belegte sonst fuer
// immer einen Platz.
setTimeout(function () { aufhoeren(eintrag); }, 5 * 60 * 1000);
var p = ton.play();
// Ein normaler Browser spielt erst nach einem Klick in die Seite. OBS
// nicht - wer hier im Browser testet, sieht es an der roten Zeile.
if (p && p.catch) p.catch(function (e) {
console.error('[sounds] Wiedergabe verweigert', e && e.name);
eintrag.zeile.textContent = (ev.name || 'Sound') + ' (erst in die Seite klicken)';
eintrag.zeile.classList.add('fehler');
setTimeout(function () { aufhoeren(eintrag); }, 3000);
});
});
Overlay.on('sound-stopp', alleStopp);
// Der Panikschalter gilt auch hier.
Overlay.on('still', alleStopp);
// Nach dem Neuverbinden nichts nachholen: ein Sound zur falschen Zeit ist
// schlimmer als keiner.
Overlay.on('state', function () { });
})();
</script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
/* Service Worker fuer das Pult.
*
* Er macht genau zwei Dinge: er ist da, damit sich das Pult als App
* installieren laesst, und er haelt die Huelle des Pults vor, damit es ohne
* Netz wenigstens aufgeht und "keine Verbindung" sagen kann statt einer
* leeren Fehlerseite des Browsers.
*
* Immer zuerst das Netz: nach dem Ausrollen soll sofort die neue Fassung
* laufen, nicht die aus dem Speicher. Alles, was nicht auf der Liste steht -
* Ereignisstrom, Knoepfe, Sounds, die Browser-Sources fuer OBS -, fasst er
* nicht an. Ein Pult, das einen Szenenwechsel aus dem Speicher beantwortet,
* waere schlimmer als gar keins. */
var SPEICHER = 'pult-v1';
var HUELLE = ['/pult', '/pult.css', '/pult.js', '/manifest.webmanifest',
'/icons/pult-192.png', '/icons/apple-touch-icon.png'];
self.addEventListener('install', function () { self.skipWaiting(); });
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (namen) {
return Promise.all(namen.filter(function (n) { return n !== SPEICHER; })
.map(function (n) { return caches.delete(n); }));
}).then(function () { return self.clients.claim(); }));
});
self.addEventListener('fetch', function (e) {
var url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== self.location.origin) return;
if (HUELLE.indexOf(url.pathname) === -1) return;
e.respondWith(fetch(e.request).then(function (res) {
// Nur Gelungenes merken. Die Anmeldeseite (401) gehoert nicht in den
// Speicher, sonst steht sie beim naechsten Funkloch statt des Pults da.
if (res.ok) {
var kopie = res.clone();
caches.open(SPEICHER).then(function (c) { c.put(url.pathname, kopie); });
}
return res;
}, function () {
return caches.match(url.pathname).then(function (r) {
return r || new Response('Keine Verbindung zum Hub.', {
status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' },
});
});
}));
});
+22
View File
@@ -119,6 +119,28 @@ Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten". Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
## Das Pult: Handy als Stream Deck
Seit 2026-10-26 gebaut, **noch nicht ausgerollt und nicht an echtem OBS
geprüft**. `/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).
- **Sounds lädt Abdu selbst hoch**, am Handy über „+ Sound“. Sie liegen auf
dem Hub unter `<db>/sounds/`, gespielt wird über die neue Browser-Source
`/sounds` (muss in OBS eingetragen werden, mit Ton).
- **Szenen** brauchen die neue Leitung Stream-PC → Hub: einmal
`abdulive fern:verbinden <adresse> <schlüssel>` auf dem Stream-PC (Befehl
steht auf der Übersicht), dann läuft sie in `abdulive ingest` mit. Das
heißt auch: **ein neues Paket für den Stream-PC** ist nötig, nicht nur
ein Ausrollen des Hubs.
- Geprüft: 311 Tests, Typprüfung, ein Durchlauf im Browser gegen einen
nachgebauten Stream-PC mit falschem OBS (Szenenwechsel, Stummschalten,
abgelehnte Szene, Hochladen, Abspielen in der Sound-Quelle). Ungeprüft:
echtes OBS, iPhone/Android-Installation, der Weg durch Nginx Proxy Manager
für die lange offene `/fern/befehle`-Leitung und Uploads bis 8 MB.
## Kanalpunkte: „Nachricht vorlesen" läuft ## Kanalpunkte: „Nachricht vorlesen" läuft
Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub
+10 -3
View File
@@ -11,9 +11,13 @@ import { forever } from '#core/retry.ts';
const log = logger('obs'); const log = logger('obs');
/** Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs. */ /**
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, outputs: 64 } as const; * Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs,
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.outputs; * dazu Inputs fuer die Stummschalter auf dem Pult.
*/
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, inputs: 8, outputs: 64 } as const;
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes |
SUBSCRIPTIONS.inputs | SUBSCRIPTIONS.outputs;
/** /**
* Antwort auf die Authentifizierungsaufforderung. * Antwort auf die Authentifizierungsaufforderung.
@@ -31,6 +35,8 @@ export type ObsEventHandlers = {
onSceneChanged?: (scene: string) => void; onSceneChanged?: (scene: string) => void;
onIdentified?: () => void; onIdentified?: () => void;
onClosed?: () => void; onClosed?: () => void;
/** Jedes Ereignis, auch die oben schon einzeln gemeldeten. */
onEvent?: (type: string, data: Record<string, unknown>) => void;
}; };
export type ObsClient = { export type ObsClient = {
@@ -105,6 +111,7 @@ export function createObs(
} else if (type === 'CurrentProgramSceneChanged') { } else if (type === 'CurrentProgramSceneChanged') {
handlers.onSceneChanged?.(String(data['sceneName'] ?? '')); handlers.onSceneChanged?.(String(data['sceneName'] ?? ''));
} }
handlers.onEvent?.(type, data);
break; break;
} }
case 7: { // RequestResponse case 7: { // RequestResponse
+8
View File
@@ -59,7 +59,15 @@ export type SecretStore = {
* Ausrollen neu. * Ausrollen neu.
*/ */
sessionKey?: string; sessionKey?: string;
/**
* Mit diesem Schluessel waehlt sich der Stream-PC beim Hub ein und
* nimmt Befehle fuer OBS entgegen. Anders als overlayKey schaltet er
* etwas, steht deshalb nie in einer Adresse.
*/
fernKey?: string;
}; };
/** Auf dem Stream-PC: wo der Hub steht und womit man sich dort meldet. */
fern?: { url: string; key: string };
bluesky?: { handle: string; appPassword: string }; bluesky?: { handle: string; appPassword: string };
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
update?: { token: string }; update?: { token: string };
+133
View File
@@ -0,0 +1,133 @@
// Zeichnet die App-Symbole fuer das Pult (PWA) und schreibt sie nach
// apps/overlay-web/public/icons/. Ohne Grafikprogramm und ohne Abhaengigkeit:
// ein paar abgerundete Rechtecke, als PNG von Hand kodiert.
//
// Aufruf: node scripts/make-icons.ts
//
// Die PNGs liegen im Repo. Neu zeichnen nur, wenn sich das Motiv aendert.
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const ZIEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
'..', 'apps', 'overlay-web', 'public', 'icons');
type Farbe = [number, number, number];
const hex = (h: string): Farbe =>
[parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const TASTEN: Farbe[] = ['#a970ff', '#4fd1c5', '#ff4fa3', '#ffc53d'].map(hex);
const OBEN = hex('#221a3a');
const UNTEN = hex('#0d0b14');
/** Abstand zum Rand eines abgerundeten Rechtecks, negativ heisst innen. */
function rundRect(x: number, y: number, x0: number, y0: number, x1: number, y1: number, r: number): number {
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const hx = (x1 - x0) / 2 - r, hy = (y1 - y0) / 2 - r;
const qx = Math.abs(x - cx) - hx, qy = Math.abs(y - cy) - hy;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}
type Motiv = {
/** Hintergrund bis zum Rand (maskable, Apple) oder selbst abgerundet. */
vollflaechig: boolean;
/** Wo die vier Tasten sitzen, als Anteil der Kantenlaenge. */
von: number; bis: number;
};
function zeichne(groesse: number, m: Motiv): Buffer {
const px = Buffer.alloc(groesse * groesse * 4);
const ss = 4; // 4x4 Unterabtastung je Pixel
const spalt = (m.bis - m.von) * 0.09;
const taste = (m.bis - m.von - spalt) / 2;
for (let py = 0; py < groesse; py++) {
for (let pxi = 0; pxi < groesse; pxi++) {
let r = 0, g = 0, b = 0, a = 0;
for (let sy = 0; sy < ss; sy++) {
for (let sx = 0; sx < ss; sx++) {
const x = (pxi + (sx + 0.5) / ss) / groesse;
const y = (py + (sy + 0.5) / ss) / groesse;
const imGrund = m.vollflaechig || rundRect(x, y, 0, 0, 1, 1, 0.22) <= 0;
if (!imGrund) continue;
let c: Farbe = [0, 1, 2].map((i) => OBEN[i]! + (UNTEN[i]! - OBEN[i]!) * y) as Farbe;
for (let i = 0; i < 4; i++) {
const tx = m.von + (i % 2) * (taste + spalt);
const ty = m.von + Math.floor(i / 2) * (taste + spalt);
if (rundRect(x, y, tx, ty, tx + taste, ty + taste, taste * 0.26) <= 0) {
// Oben etwas heller: sieht nach Taste aus statt nach Flaeche.
const hell = 1.12 - 0.22 * ((y - ty) / taste);
c = TASTEN[i]!.map((v) => Math.min(255, v * hell)) as Farbe;
}
}
r += c[0]; g += c[1]; b += c[2]; a += 255;
}
}
const n = ss * ss, o = (py * groesse + pxi) * 4;
// Vormultipliziert gemittelt, damit die Kanten nicht dunkel ausfransen.
const deck = a / n;
px[o] = deck ? Math.round(r / (a / 255)) : 0;
px[o + 1] = deck ? Math.round(g / (a / 255)) : 0;
px[o + 2] = deck ? Math.round(b / (a / 255)) : 0;
px[o + 3] = Math.round(deck);
}
}
return png(groesse, groesse, px);
}
// ---------------------------------------------------------------- PNG
const CRC = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(b: Buffer): number {
let c = 0xffffffff;
for (const v of b) c = CRC[(c ^ v) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function block(typ: string, daten: Buffer): Buffer {
const len = Buffer.alloc(4); len.writeUInt32BE(daten.length);
const td = Buffer.concat([Buffer.from(typ, 'latin1'), daten]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function png(w: number, h: number, rgba: Buffer): Buffer {
const kopf = Buffer.alloc(13);
kopf.writeUInt32BE(w, 0); kopf.writeUInt32BE(h, 4);
kopf[8] = 8; kopf[9] = 6; kopf[10] = 0; kopf[11] = 0; kopf[12] = 0; // 8 bit RGBA
const zeilen = Buffer.alloc((w * 4 + 1) * h);
for (let y = 0; y < h; y++) rgba.copy(zeilen, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
block('IHDR', kopf),
block('IDAT', zlib.deflateSync(zeilen, { level: 9 })),
block('IEND', Buffer.alloc(0)),
]);
}
// ---------------------------------------------------------------- los
const NORMAL: Motiv = { vollflaechig: false, von: 0.2, bis: 0.8 };
// Maskable: alles Wichtige im Kreis mit 40 % Radius, der Rest wird abgeschnitten.
const MASKE: Motiv = { vollflaechig: true, von: 0.29, bis: 0.71 };
// Apple rundet selbst und mag keine Durchsicht.
const APPLE: Motiv = { vollflaechig: true, von: 0.22, bis: 0.78 };
fs.mkdirSync(ZIEL, { recursive: true });
const dateien: Array<[string, number, Motiv]> = [
['pult-192.png', 192, NORMAL],
['pult-512.png', 512, NORMAL],
['pult-maskable-512.png', 512, MASKE],
['apple-touch-icon.png', 180, APPLE],
];
for (const [name, groesse, motiv] of dateien) {
const daten = zeichne(groesse, motiv);
fs.writeFileSync(path.join(ZIEL, name), daten);
console.log(`${name} ${groesse}x${groesse} ${(daten.length / 1024).toFixed(1)} KB`);
}