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

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

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

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
// Die Sounds fuer das Pult.
//
// Hochgeladen wird vom Handy aus, abgespielt in OBS ueber die Browser-Source
// /sounds. Dazwischen liegt nur dieser Ordner:
//
// <db>/sounds/sounds.json Name, Emoji, Farbe, Lautstaerke, Reihenfolge
// <db>/sounds/<id>.<endung> die Datei selbst
//
// Bewusst neben der Datenbank und nicht in ihr: ein Ordner mit Dateien laesst
// sich von Hand durchsehen und sichern, und die Datenbank bleibt klein. Und
// bewusst nicht unter /opt/abdulive: das wird bei jedem Ausrollen ersetzt.
//
// Der Dateityp wird an den ersten Bytes erkannt, nicht an dem, was der Browser
// behauptet. Ausgeliefert wird nur, was hier als Ton erkannt wurde - sonst
// waere das eine Ablage fuer beliebige Dateien unter der eigenen Adresse.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export type Sound = {
id: string;
name: string;
emoji: string;
/** Eine der FARBEN unten. Freie Werte gaebe es nur fuer CSS-Einschleusung. */
farbe: string;
/** 0 bis 1. Multipliziert mit der Lautstaerke der Browser-Source. */
laut: number;
datei: string;
typ: string;
bytes: number;
seit: string;
};
export const FARBEN = ['lila', 'tuerkis', 'pink', 'gold', 'gruen', 'rot', 'blau', 'grau'] as const;
/** Groesser ist kein Soundeffekt mehr, sondern ein Lied. */
export const MAX_BYTES = 8 * 1024 * 1024;
export const MAX_SOUNDS = 120;
const ENDUNG: Record<string, string> = {
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
'audio/mp4': '.m4a',
};
/**
* Tontyp an den ersten Bytes erkennen. `null` heisst: kein Ton, den OBS'
* Browser sicher abspielt.
*/
export function erkenneTyp(b: Buffer): string | null {
if (b.length < 12) return null;
const ascii = (von: number, bis: number): string => b.subarray(von, bis).toString('latin1');
if (ascii(0, 3) === 'ID3') return 'audio/mpeg';
// MPEG-Rahmen ohne ID3-Kopf: 11 gesetzte Synchronbits.
if (b[0] === 0xff && (b[1]! & 0xe0) === 0xe0) return 'audio/mpeg';
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WAVE') return 'audio/wav';
if (ascii(0, 4) === 'OggS') return 'audio/ogg';
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'audio/webm';
// MP4/M4A: das Kaestchen `ftyp` an Stelle 4. So nimmt auch das iPhone auf.
if (ascii(4, 8) === 'ftyp') return 'audio/mp4';
return null;
}
/** Name aus dem Dateinamen: ohne Endung, ohne Unterstriche, nicht zu lang. */
export function nameAusDatei(datei: string): string {
const ohne = datei.replace(/\.[A-Za-z0-9]{1,5}$/, '').replace(/[_]+/g, ' ').trim();
return saeubern(ohne) || 'Sound';
}
function saeubern(s: string, max = 40): string {
// Steuerzeichen raus. Angezeigt wird ueber textContent, aber ein
// Zeilenumbruch im Namen zerreisst trotzdem jede Kachel.
return Array.from(s.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim())
.slice(0, max).join('');
}
function emojiOk(s: string): string {
// Ein Emoji kann aus mehreren Codepoints bestehen (Hautfarbe, Familien).
// Zehn reichen fuer alles, was man auf eine Kachel setzt.
return Array.from(saeubern(s, 10)).slice(0, 10).join('');
}
export type Soundboard = {
liste: () => Sound[];
holen: (id: string) => Sound | undefined;
datei: (id: string) => { daten: Buffer; typ: string } | undefined;
hinzufuegen: (name: string, daten: Buffer) => Sound;
aendern: (id: string, was: Partial<Pick<Sound, 'name' | 'emoji' | 'farbe' | 'laut'>>) => Sound | undefined;
entfernen: (id: string) => boolean;
/** Um eine Stelle nach vorn (-1) oder hinten (+1). */
verschieben: (id: string, um: number) => boolean;
};
export function createSoundboard(ordner: string): Soundboard {
const index = path.join(ordner, 'sounds.json');
const sounds: Sound[] = lesen();
function lesen(): Sound[] {
try {
const roh = JSON.parse(fs.readFileSync(index, 'utf8')) as { sounds?: Sound[] };
return Array.isArray(roh.sounds) ? roh.sounds : [];
} catch {
return [];
}
}
function schreiben(): void {
fs.mkdirSync(ordner, { recursive: true });
// Erst daneben schreiben, dann umbenennen: stirbt der Prozess mittendrin,
// bleibt die alte Liste ganz statt einer halben neuen.
const tmp = `${index}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sounds }, null, 2));
fs.renameSync(tmp, index);
}
const holen = (id: string): Sound | undefined => sounds.find((s) => s.id === id);
return {
liste: () => sounds.map((s) => ({ ...s })),
holen,
datei(id) {
const s = holen(id);
if (!s) return undefined;
try {
return { daten: fs.readFileSync(path.join(ordner, s.datei)), typ: s.typ };
} catch {
return undefined;
}
},
hinzufuegen(name, daten) {
if (daten.length > MAX_BYTES) {
throw new Error(`Zu gross: höchstens ${MAX_BYTES / 1024 / 1024} MB.`);
}
if (sounds.length >= MAX_SOUNDS) throw new Error(`Höchstens ${MAX_SOUNDS} Sounds.`);
const typ = erkenneTyp(daten);
if (!typ) throw new Error('Kein Ton, den OBS abspielt. Erlaubt sind MP3, WAV, OGG, WebM und M4A.');
const id = crypto.randomBytes(6).toString('hex');
const datei = `${id}${ENDUNG[typ]}`;
fs.mkdirSync(ordner, { recursive: true });
fs.writeFileSync(path.join(ordner, datei), daten);
const s: Sound = {
id, name: saeubern(name) || 'Sound', emoji: '', farbe: FARBEN[sounds.length % FARBEN.length]!,
laut: 0.8, datei, typ, bytes: daten.length, seit: new Date().toISOString(),
};
sounds.push(s);
schreiben();
return { ...s };
},
aendern(id, was) {
const s = holen(id);
if (!s) return undefined;
if (was.name !== undefined) s.name = saeubern(was.name) || s.name;
if (was.emoji !== undefined) s.emoji = emojiOk(was.emoji);
if (was.farbe !== undefined && (FARBEN as readonly string[]).includes(was.farbe)) s.farbe = was.farbe;
if (was.laut !== undefined && Number.isFinite(was.laut)) {
s.laut = Math.round(Math.max(0, Math.min(1, was.laut)) * 100) / 100;
}
schreiben();
return { ...s };
},
entfernen(id) {
const i = sounds.findIndex((s) => s.id === id);
if (i === -1) return false;
const [weg] = sounds.splice(i, 1);
schreiben();
try { fs.unlinkSync(path.join(ordner, weg!.datei)); } catch { /* schon weg */ }
return true;
},
verschieben(id, um) {
const i = sounds.findIndex((s) => s.id === id);
const ziel = i + Math.sign(um);
if (i === -1 || ziel < 0 || ziel >= sounds.length) return false;
[sounds[i], sounds[ziel]] = [sounds[ziel]!, sounds[i]!];
schreiben();
return true;
},
};
}