From 8be195d9382abcb4c3b7b4bb104fb2f8bee6455c Mon Sep 17 00:00:00 2001 From: ZeasyAlex Date: Mon, 26 Oct 2026 11:51:48 +0100 Subject: [PATCH] Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel - /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 --- apps/cli/main.ts | 50 ++ apps/ingest/src/fern.test.ts | 69 ++ apps/ingest/src/fern.ts | 247 +++++++ apps/ingest/src/index.ts | 21 +- apps/overlay-server/README.md | 6 + apps/overlay-server/src/auth.test.ts | 11 + apps/overlay-server/src/auth.ts | 29 + apps/overlay-server/src/bus.ts | 38 +- apps/overlay-server/src/fern.test.ts | 116 ++++ apps/overlay-server/src/fern.ts | 155 +++++ apps/overlay-server/src/server.ts | 299 +++++++- apps/overlay-server/src/sounds.test.ts | 89 +++ apps/overlay-server/src/sounds.ts | 187 +++++ apps/overlay-web/README.md | 46 +- apps/overlay-web/public/client.js | 6 +- .../public/icons/apple-touch-icon.png | Bin 0 -> 3114 bytes apps/overlay-web/public/icons/pult-192.png | Bin 0 -> 4424 bytes apps/overlay-web/public/icons/pult-512.png | Bin 0 -> 10518 bytes .../public/icons/pult-maskable-512.png | Bin 0 -> 7077 bytes apps/overlay-web/public/manifest.webmanifest | 21 + apps/overlay-web/public/pult.css | 408 +++++++++++ apps/overlay-web/public/pult.html | 195 ++++++ apps/overlay-web/public/pult.js | 638 ++++++++++++++++++ apps/overlay-web/public/sounds.html | 109 +++ apps/overlay-web/public/sw.js | 47 ++ docs/handoff.md | 22 + packages/obs/src/client.ts | 13 +- packages/twitch/src/secrets.ts | 8 + scripts/make-icons.ts | 133 ++++ 29 files changed, 2935 insertions(+), 28 deletions(-) create mode 100644 apps/ingest/src/fern.test.ts create mode 100644 apps/ingest/src/fern.ts create mode 100644 apps/overlay-server/src/fern.test.ts create mode 100644 apps/overlay-server/src/fern.ts create mode 100644 apps/overlay-server/src/sounds.test.ts create mode 100644 apps/overlay-server/src/sounds.ts create mode 100644 apps/overlay-web/public/icons/apple-touch-icon.png create mode 100644 apps/overlay-web/public/icons/pult-192.png create mode 100644 apps/overlay-web/public/icons/pult-512.png create mode 100644 apps/overlay-web/public/icons/pult-maskable-512.png create mode 100644 apps/overlay-web/public/manifest.webmanifest create mode 100644 apps/overlay-web/public/pult.css create mode 100644 apps/overlay-web/public/pult.html create mode 100644 apps/overlay-web/public/pult.js create mode 100644 apps/overlay-web/public/sounds.html create mode 100644 apps/overlay-web/public/sw.js create mode 100644 scripts/make-icons.ts diff --git a/apps/cli/main.ts b/apps/cli/main.ts index db3dfb2..a18139d 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -690,6 +690,51 @@ async function webPasswort(args: string[]): Promise { 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 { + 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 (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 /** @@ -901,6 +946,10 @@ abdulive - Werkzeugkasten fuer Abdus Stream ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern) overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb web:passwort [wort] Passwort fuer die Weboberflaeche setzen + fern:verbinden + 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 db:migrate Migrationen anwenden fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) @@ -956,6 +1005,7 @@ try { } case 'overlay': await overlayOnly(args); break; case 'web:passwort': await webPasswort(args); break; + case 'fern:verbinden': await fernVerbinden(args); break; case 'discord:bot': await discordBot(); break; case 'help': case undefined: help(); break; default: diff --git a/apps/ingest/src/fern.test.ts b/apps/ingest/src/fern.test.ts new file mode 100644 index 0000000..10016ce --- /dev/null +++ b/apps/ingest/src/fern.test.ts @@ -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) => unknown>): ObsClient { + return { + run: async () => {}, + isConnected: () => true, + request: async (type: string, data?: unknown): Promise => { + const f = antworten[type]; + if (!f) throw new Error(`OBS lehnt ab (604): ${type}`); + return f((data ?? {}) as Record) 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']); +}); diff --git a/apps/ingest/src/fern.ts b/apps/ingest/src/fern.ts new file mode 100644 index 0000000..218aaf3 --- /dev/null +++ b/apps/ingest/src/fern.ts @@ -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 `; 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; + 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 { + 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; + /** 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 { + 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 => { + 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 { + 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) { + 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, + }; +} diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts index 949c2a0..723fdae 100644 --- a/apps/ingest/src/index.ts +++ b/apps/ingest/src/index.ts @@ -19,6 +19,7 @@ import { readSecrets } from '#twitch/secrets.ts'; import { streamWechsel } from '#twitch/stream-state.ts'; import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; import { createClipper } from './clips.ts'; +import { createFernanschluss } from './fern.ts'; import { createRenderer } from './render.ts'; import { createSpikeDetector } from './spike.ts'; import { startServer } from './server.ts'; @@ -62,8 +63,9 @@ export async function runIngest(): Promise { cfg.obs.url, () => readSecrets().obs?.password, { - onIdentified: () => { state.obsConnected = true; }, - onClosed: () => { state.obsConnected = false; }, + onIdentified: () => { state.obsConnected = true; fern?.obsWechsel(); }, + onClosed: () => { state.obsConnected = false; fern?.obsWechsel(); }, + onEvent: (type) => fern?.obsEreignis(type), onSceneChanged: (scene) => { state.obsScene = scene; repo.event('obs.scene_changed', { scene }, state.streamId); @@ -114,6 +116,20 @@ export async function runIngest(): Promise { 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 const announcer = createAnnouncer({ cfg: { @@ -138,6 +154,7 @@ export async function runIngest(): Promise { goLive: announcer.lastResult() ?? state.goLive, spikeArmed: spikes.isArmed(), clipsWaitingForFile: clipper.pendingCount(), + pult: fern ? (fern.verbunden() ? 'verbunden' : 'getrennt') : 'nicht eingerichtet', renderQueue: renderer.pending(), rendering: renderer.currentJob(), note: state.chatAnonymous diff --git a/apps/overlay-server/README.md b/apps/overlay-server/README.md index 5613ad2..07a9d5b 100644 --- a/apps/overlay-server/README.md +++ b/apps/overlay-server/README.md @@ -10,8 +10,14 @@ offen und bringt Einrichtung und OAuth-Rückruf unter dieselbe Adresse. /callback OAuth-Rückruf von Twitch offen, `state` schützt /alerts … die Browser-Sources 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 [`chat-control.md`](../../docs/projects/chat-control.md) skizziert einen diff --git a/apps/overlay-server/src/auth.test.ts b/apps/overlay-server/src/auth.test.ts index af1358a..9621e5e 100644 --- a/apps/overlay-server/src/auth.test.ts +++ b/apps/overlay-server/src/auth.test.ts @@ -66,3 +66,14 @@ test('zwei Anmeldungen mit demselben Ablauf ergeben dasselbe Cookie', () => { // beenden lässt - dafür gibt es den Passwortwechsel. 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); +}); diff --git a/apps/overlay-server/src/auth.ts b/apps/overlay-server/src/auth.ts index 95c2c7b..0d7a161 100644 --- a/apps/overlay-server/src/auth.ts +++ b/apps/overlay-server/src/auth.ts @@ -35,6 +35,10 @@ export type Zugang = { abmelden: () => string; overlayKey: () => 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. */ hatPasswort: () => boolean; }; @@ -88,6 +92,11 @@ function signatur(key: Buffer, passwordHash: string, bis: string): string { .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 { const out: Record = {}; for (const teil of (req.headers.cookie ?? '').split(';')) { @@ -114,6 +123,14 @@ export function createZugang(): Zugang { 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 * 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`, 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() { const neu = crypto.randomBytes(18).toString('base64url'); updateSecrets((s) => { s.web = { ...s.web, overlayKey: neu }; }); diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts index 8215b11..6f8c709 100644 --- a/apps/overlay-server/src/bus.ts +++ b/apps/overlay-server/src/bus.ts @@ -78,6 +78,25 @@ export type LiegestuetzeEvent = { 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 = | ChatEvent | AlertEvent @@ -103,12 +122,24 @@ export type OverlayEvent = | { kind: 'still' } | LiegestuetzeEvent | { 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: 'beat'; t: number } | { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[]; /** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */ 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, }; let liegestuetze = { offen: 0, gemacht: 0 }; + let obs: ObsStandEvent = { + kind: 'obs', pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false, + }; return { publish(ev) { @@ -152,6 +186,8 @@ export function createBus(): Bus { stream = ev; } else if (ev.kind === 'liegestuetze') { liegestuetze = { offen: ev.offen, gemacht: ev.gemacht }; + } else if (ev.kind === 'obs') { + obs = ev; } else if (ev.kind === 'clear') { // Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der // naechste Neustart der Browser-Source es zurueck ins Bild. @@ -174,7 +210,7 @@ export function createBus(): Bus { 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, }; } diff --git a/apps/overlay-server/src/fern.test.ts b/apps/overlay-server/src/fern.test.ts new file mode 100644 index 0000000..18b96bc --- /dev/null +++ b/apps/overlay-server/src/fern.test.ts @@ -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> => res.geschrieben + .filter((s) => s.startsWith('data: ')) + .map((s) => JSON.parse(s.slice(6)) as Record) + .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): 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): 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), '/'); +}); diff --git a/apps/overlay-server/src/fern.ts b/apps/overlay-server/src/fern.ts new file mode 100644 index 0000000..2f54d5d --- /dev/null +++ b/apps/overlay-server/src/fern.ts @@ -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; + verbunden: () => boolean; +}; + +const LEER: Omit = { + 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 { + const r = (roh && typeof roh === 'object' ? roh : {}) as Record; + 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)?.['name']); + return name === null ? [] : [{ name, stumm: (t as Record)['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 void; timer: NodeJS.Timeout }>(); + + const veroeffentlichen = (stand: Omit): 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; + 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((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, + }; +} diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts index 5856bd2..2cead66 100644 --- a/apps/overlay-server/src/server.ts +++ b/apps/overlay-server/src/server.ts @@ -8,6 +8,8 @@ // /callback OAuth-Rueckruf von Twitch (offen, state schuetzt) // /alerts ... die Browser-Sources (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 // 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 path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { paths } from '#core/config.ts'; import { logger } from '#core/logger.ts'; import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts'; import { createZugang, type Zugang } from './auth.ts'; import { createBus, type Bus, type OverlayEvent } from './bus.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'; const log = logger('overlay'); @@ -46,6 +51,7 @@ const TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', + '.webmanifest': 'application/manifest+json; charset=utf-8', }; /** 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.' }, { path: '/tts', file: 'tts.html', w: 600, h: 200, title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' }, + { path: '/sounds', file: 'sounds.html', w: 400, h: 120, + title: 'Soundboard', hint: 'Spielt die Sounds vom Pult. Unsichtbar, braucht Ton in OBS („Audio über OBS steuern“ an).' }, { path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200, title: 'Liegestütze', hint: 'Offene und in diesem Stream gemachte Liegestütze. Zählt bei Einlösung hoch, bedienen unten auf dieser Seite.' }, ] as const; @@ -73,6 +81,7 @@ type LiegestuetzStand = { offen: number; gemacht: number }; export type OverlayServer = { bus: Bus; zugang: Zugang; + fern: Fernsteuerung; close: () => void; port: number; }; @@ -83,6 +92,8 @@ export type OverlayOptions = { /** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */ publicUrl: string; bus?: Bus; + /** Wo die Sounds liegen. Vorgabe: neben der Datenbank. */ + soundsDir?: string; /** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */ setup?: ServerDeps; /** @@ -116,13 +127,18 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer { const zugang = createZugang(); // Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll // 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 // 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 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}`); if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); 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.` : '')); }); - 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 }; +/** 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; +}; + async function handle( req: http.IncomingMessage, res: http.ServerResponse, - bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung + bus: Bus, zugang: Zugang, opts: OverlayOptions, lz: Laufzeit ): Promise { const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`); const p = url.pathname; + const { vorfuehrung, sounds, fern } = lz; // ---- Anmeldung. Bewusst vor allem anderen. if (p === '/login' && req.method === 'POST') { - const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? ''); - if (!cookie) return redirect(res, '/?fehler=1'); - res.writeHead(303, { location: '/', 'set-cookie': cookie }); + const form = await readForm(req); + const weiter = sichererPfad(form.get('weiter')); + 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(); return; } @@ -177,7 +205,25 @@ async function handle( // ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten, // 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. // 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' ? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined; 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)) { return send(res, 403, 'text/plain; charset=utf-8', 'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' + 'Sie steht fertig auf der Uebersichtsseite.'); } - if (p === '/events') return sse(req, res, bus); + if (p === '/events') return sse(req, res, bus, url, lz.quellen); + if (soundId) { + const d = sounds.datei(soundId); + if (!d) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.'); + res.writeHead(200, { + 'content-type': d.typ, 'content-length': d.daten.length, + // Darf zwischengespeichert werden: eine ID gehoert immer zu derselben + // Datei, und beim zweiten Druck auf denselben Knopf soll es sofort knallen. + 'cache-control': 'private, max-age=86400, immutable', + 'x-content-type-options': 'nosniff', + }); + res.end(d.daten); + return; + } if (audioId) { const wav = opts.tts?.audio(audioId); if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.'); @@ -207,10 +267,39 @@ async function handle( // ---- Ab hier nur mit Anmeldung. 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', - 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 // davon landet in der Datenbank, auf Discord oder bei Twitch. if (p === '/test' && req.method === 'POST') { @@ -323,10 +412,15 @@ async function handle( zugang.neuerOverlayKey(); return redirect(res, '/'); } + if (p === '/fernschluessel/neu' && req.method === 'POST') { + zugang.neuerFernKey(); + return redirect(res, '/#stream-pc'); + } if (p === '/' || p === '/index.html') { 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; @@ -336,7 +430,16 @@ async function handle( // ---------------------------------------------------------------- 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 +): 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, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', @@ -366,7 +469,15 @@ function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): voi HEARTBEAT_MS); 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); res.on('error', end); } @@ -378,8 +489,119 @@ function zustand(bus: Bus) { 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 { + const p = url.pathname; + const { sounds } = lz; + const json = (code: number, body: Record): 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[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 +/** + * 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 { + 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 { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -486,13 +708,19 @@ const SEITEN_CSS = ` button.tts { margin-left: .4rem; } `; -export function loginPage(channel: string, fehler: boolean): string { +export function loginPage(channel: string, fehler: boolean, weiter = '/'): string { return ` + + + + Anmelden
+

abdulive ${escape(channel)}

${fehler ? 'Passwort stimmt nicht.' : 'Passwort eingeben.'}

@@ -507,7 +735,8 @@ export function loginPage(channel: string, fehler: boolean): string { * die Seitenliste, und das soll nicht an zwei Stellen stehen. */ 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 { // Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt - // 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.

).join('')} `).join(''); + const fernBefehl = `abdulive fern:verbinden ${base} ${fernInfo.key}`; + return ` -Overlay - ${escape(opts.channel)} + + + + +Overlay - ${escape(opts.channel)}

Overlay ${escape(opts.channel)}

+
+ Pult öffnen + 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. +

In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst verpasst das Overlay alles, was in einer anderen Szene passiert.

@@ -607,6 +854,20 @@ ${ttsFeld} ${lzFeld} +

Stream-PC verbinden

+

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 abdulive ingest neu starten:

+

${escape(fernBefehl)} +

+

${ + fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}

+ + + 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. +
+

Schlüssel

diff --git a/apps/overlay-server/src/sounds.test.ts b/apps/overlay-server/src/sounds.test.ts new file mode 100644 index 0000000..3d66166 --- /dev/null +++ b/apps/overlay-server/src/sounds.test.ts @@ -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('')), null); + assert.equal(erkenneTyp(Buffer.from('kurz')), null); + const sb = createSoundboard(ordner()); + assert.throws(() => sb.hinzufuegen('x', Buffer.from('hallo welt')), /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'); +}); diff --git a/apps/overlay-server/src/sounds.ts b/apps/overlay-server/src/sounds.ts new file mode 100644 index 0000000..c4c7307 --- /dev/null +++ b/apps/overlay-server/src/sounds.ts @@ -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: +// +// /sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge +// /sounds/. 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 = { + '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>) => 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; + }, + }; +} diff --git a/apps/overlay-web/README.md b/apps/overlay-web/README.md index f640ca5..d9db277 100644 --- a/apps/overlay-web/README.md +++ b/apps/overlay-web/README.md @@ -17,6 +17,7 @@ und [`client.js`](public/client.js) (Verbindung, Escaping, Emotes). | `/status` | 1920×160 | Titel, Kategorie, Zuschauer, Laufzeit. | | `/effects` | 1920×1080 | Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids. | | `/starting` | 1920×1080 | Pausenbild als eigene Szene. Deckend, nicht durchsichtig. | +| `/sounds` | 400×120 | Spielt die Sounds vom Pult. Unsichtbar, **mit Ton**. | 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. - **BTTV- und 7TV-Emotes.** Die hängen an einer zweiten API. Twitchs eigene Emotes werden angezeigt. -- **Töne.** Es liegen keine Audiodateien im Repo, und ein Overlay, das - ungefragt Krach macht, ist schlimmer als eines, das still ist. +- **Töne bei Einblendungen.** Es liegen keine Audiodateien im Repo, und ein + 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 (`/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 +``` + +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`) diff --git a/apps/overlay-web/public/client.js b/apps/overlay-web/public/client.js index 9ea24a5..5e6af3d 100644 --- a/apps/overlay-web/public/client.js +++ b/apps/overlay-web/public/client.js @@ -27,7 +27,11 @@ function verbinde() { 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); letzteNachricht = Date.now(); diff --git a/apps/overlay-web/public/icons/apple-touch-icon.png b/apps/overlay-web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0738daa8dc44e75c94e55bb6eccf00e03a747a38 GIT binary patch literal 3114 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 B6h8m} literal 0 HcmV?d00001 diff --git a/apps/overlay-web/public/icons/pult-192.png b/apps/overlay-web/public/icons/pult-192.png new file mode 100644 index 0000000000000000000000000000000000000000..41bf16782f8b6f22935e7d272cc4d6a71d72621c GIT binary patch literal 4424 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 L{ll5RSzh}W-2)@M literal 0 HcmV?d00001 diff --git a/apps/overlay-web/public/manifest.webmanifest b/apps/overlay-web/public/manifest.webmanifest new file mode 100644 index 0000000..cca7fb5 --- /dev/null +++ b/apps/overlay-web/public/manifest.webmanifest @@ -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" }] } + ] +} diff --git a/apps/overlay-web/public/pult.css b/apps/overlay-web/public/pult.css new file mode 100644 index 0000000..9de697d --- /dev/null +++ b/apps/overlay-web/public/pult.css @@ -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; } +} diff --git a/apps/overlay-web/public/pult.html b/apps/overlay-web/public/pult.html new file mode 100644 index 0000000..15013c3 --- /dev/null +++ b/apps/overlay-web/public/pult.html @@ -0,0 +1,195 @@ + + + + + + + + + + + +Pult · abdulive + + + + + + + +
+
+ abdulive + offline +
+
+ + +
+
+ + + +
+ +
+
+ + + + + +
+ +
+ +
+ + +
+ + +
+ + +
+ + + + + +

Bricht Vorlesen und alle Sounds ab und leert die Warteschlange.

+
+ + +
+ +

Erfundene Ereignisse, nur für die Browser-Sources. Nichts davon landet + in der Datenbank, auf Discord oder bei Twitch.

+
+

Dauerbetrieb

+ +

Übersicht und OBS-Adressen · Einrichtung · Abmelden

+
+
+ +
+ + + + + + +
+ + +
+
+
+ Farbe +
+
+ +
+ + + +
+
+ + + + +
+ +
+ + + + + + diff --git a/apps/overlay-web/public/pult.js b/apps/overlay-web/public/pult.js new file mode 100644 index 0000000..bbb054f --- /dev/null +++ b/apps/overlay-web/public/pult.js @@ -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 + ? '' + : ''; + 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); + }); + } +})(); diff --git a/apps/overlay-web/public/sounds.html b/apps/overlay-web/public/sounds.html new file mode 100644 index 0000000..39df293 --- /dev/null +++ b/apps/overlay-web/public/sounds.html @@ -0,0 +1,109 @@ + + + + +abdulive - Soundboard + + + + +
+ + + + + diff --git a/apps/overlay-web/public/sw.js b/apps/overlay-web/public/sw.js new file mode 100644 index 0000000..3e26921 --- /dev/null +++ b/apps/overlay-web/public/sw.js @@ -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' }, + }); + }); + })); +}); diff --git a/docs/handoff.md b/docs/handoff.md index d4eca14..7e012d7 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -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 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 `/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 ` 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 Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub diff --git a/packages/obs/src/client.ts b/packages/obs/src/client.ts index 195d4c8..734df07 100644 --- a/packages/obs/src/client.ts +++ b/packages/obs/src/client.ts @@ -11,9 +11,13 @@ import { forever } from '#core/retry.ts'; 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; -const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.outputs; +/** + * Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und 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. @@ -31,6 +35,8 @@ export type ObsEventHandlers = { onSceneChanged?: (scene: string) => void; onIdentified?: () => void; onClosed?: () => void; + /** Jedes Ereignis, auch die oben schon einzeln gemeldeten. */ + onEvent?: (type: string, data: Record) => void; }; export type ObsClient = { @@ -105,6 +111,7 @@ export function createObs( } else if (type === 'CurrentProgramSceneChanged') { handlers.onSceneChanged?.(String(data['sceneName'] ?? '')); } + handlers.onEvent?.(type, data); break; } case 7: { // RequestResponse diff --git a/packages/twitch/src/secrets.ts b/packages/twitch/src/secrets.ts index 6048352..3b7ca8f 100644 --- a/packages/twitch/src/secrets.ts +++ b/packages/twitch/src/secrets.ts @@ -59,7 +59,15 @@ export type SecretStore = { * Ausrollen neu. */ 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 }; /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ update?: { token: string }; diff --git a/scripts/make-icons.ts b/scripts/make-icons.ts new file mode 100644 index 0000000..14ae01a --- /dev/null +++ b/scripts/make-icons.ts @@ -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`); +}