Pult: Handy als Stream Deck mit Soundboard und Szenenwechsel
Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
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:
1 parent
70448967f5
commit
8be195d938
29 files changed
+2935
-28
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// Fernsteuerung fuer OBS, Hub-Seite.
|
||||
//
|
||||
// OBS laeuft auf dem Stream-PC, das Pult am Handy spricht mit dem Hub. Der
|
||||
// Hub kommt an den Stream-PC nicht heran - der steht hinter einem fremden
|
||||
// Router. Also waehlt sich der PC beim Hub ein, nicht umgekehrt:
|
||||
//
|
||||
// Handy --POST /pult/obs--> Hub --SSE /fern/befehle--> Stream-PC --> OBS
|
||||
// Handy <--SSE /events---- Hub <--POST /fern/stand---- Stream-PC
|
||||
//
|
||||
// Dieselbe Bauform wie bei den Browser-Sources (SSE statt WebSocket, siehe
|
||||
// README), nur mit einem eigenen Schluessel: der fuer OBS darf nur lesen,
|
||||
// dieser hier schaltet Szenen um.
|
||||
//
|
||||
// Jeder Befehl wartet auf eine Antwort vom PC. Ein Knopf, der "erledigt"
|
||||
// meldet, waehrend OBS gar nicht erreichbar war, ist am Handy schlimmer als
|
||||
// einer, der ehrlich sagt, dass es nicht ging.
|
||||
|
||||
import type http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import type { Bus, ObsStandEvent } from './bus.ts';
|
||||
|
||||
const log = logger('overlay:fern');
|
||||
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const ANTWORT_MS = 6_000;
|
||||
|
||||
export type FernBefehl =
|
||||
| { art: 'szene'; szene: string }
|
||||
| { art: 'clip' }
|
||||
| { art: 'stumm'; quelle: string; stumm: boolean };
|
||||
|
||||
export type FernAntwort = { ok: boolean; text: string };
|
||||
|
||||
export type Fernsteuerung = {
|
||||
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
|
||||
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||
/** Stand von OBS, wie ihn der PC meldet. */
|
||||
melden: (roh: unknown) => void;
|
||||
antwort: (roh: unknown) => void;
|
||||
senden: (b: FernBefehl) => Promise<FernAntwort>;
|
||||
verbunden: () => boolean;
|
||||
};
|
||||
|
||||
const LEER: Omit<ObsStandEvent, 'kind'> = {
|
||||
pc: false, obs: false, szenen: [], aktuell: null, ton: [], puffer: false,
|
||||
};
|
||||
|
||||
/** Was vom PC kommt, auf die erwartete Form bringen. Fremdes faellt weg. */
|
||||
export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
|
||||
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
|
||||
const text = (v: unknown): string | null => (typeof v === 'string' && v.length <= 200 ? v : null);
|
||||
const szenen = Array.isArray(r['szenen'])
|
||||
? r['szenen'].map(text).filter((s): s is string => s !== null).slice(0, 100) : [];
|
||||
const ton = Array.isArray(r['ton'])
|
||||
? r['ton'].flatMap((t) => {
|
||||
const name = text((t as Record<string, unknown>)?.['name']);
|
||||
return name === null ? [] : [{ name, stumm: (t as Record<string, unknown>)['stumm'] === true }];
|
||||
}).slice(0, 50) : [];
|
||||
return {
|
||||
obs: r['obs'] === true,
|
||||
szenen,
|
||||
aktuell: text(r['aktuell']),
|
||||
ton,
|
||||
puffer: r['puffer'] === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
||||
let leitung: http.ServerResponse | null = null;
|
||||
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
|
||||
|
||||
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
|
||||
bus.publish({ kind: 'obs', ...stand });
|
||||
};
|
||||
|
||||
return {
|
||||
annehmen(req, res) {
|
||||
if (leitung) {
|
||||
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
|
||||
// Der neuere gewinnt, meist ist der alte ohnehin eine tote Leitung.
|
||||
log.info('Stream-PC hat sich neu verbunden, alte Leitung geschlossen');
|
||||
leitung.end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
leitung = res;
|
||||
log.info('Stream-PC verbunden');
|
||||
veroeffentlichen({ ...LEER, pc: true });
|
||||
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
|
||||
HEARTBEAT_MS);
|
||||
beat.unref();
|
||||
|
||||
const ende = (): void => {
|
||||
clearInterval(beat);
|
||||
if (leitung !== res) return; // schon durch eine neuere ersetzt
|
||||
leitung = null;
|
||||
log.info('Stream-PC getrennt');
|
||||
veroeffentlichen(LEER);
|
||||
for (const [id, o] of offen) {
|
||||
clearTimeout(o.timer);
|
||||
o.fertig({ ok: false, text: 'Verbindung zum Stream-PC abgerissen.' });
|
||||
offen.delete(id);
|
||||
}
|
||||
};
|
||||
req.on('close', ende);
|
||||
res.on('error', ende);
|
||||
},
|
||||
|
||||
melden(roh) {
|
||||
if (!leitung) return;
|
||||
veroeffentlichen({ pc: true, ...standAus(roh) });
|
||||
},
|
||||
|
||||
antwort(roh) {
|
||||
const r = (roh && typeof roh === 'object' ? roh : {}) as Record<string, unknown>;
|
||||
const id = typeof r['id'] === 'string' ? r['id'] : '';
|
||||
const o = offen.get(id);
|
||||
if (!o) return;
|
||||
offen.delete(id);
|
||||
clearTimeout(o.timer);
|
||||
o.fertig({
|
||||
ok: r['ok'] === true,
|
||||
text: typeof r['text'] === 'string' ? r['text'].slice(0, 300) : '',
|
||||
});
|
||||
},
|
||||
|
||||
senden(b) {
|
||||
const res = leitung;
|
||||
if (!res) {
|
||||
return Promise.resolve({
|
||||
ok: false, text: 'Der Stream-PC ist nicht verbunden. Läuft dort abdulive?',
|
||||
});
|
||||
}
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
return new Promise<FernAntwort>((fertig) => {
|
||||
const timer = setTimeout(() => {
|
||||
offen.delete(id);
|
||||
fertig({ ok: false, text: 'Der Stream-PC hat nicht geantwortet.' });
|
||||
}, ANTWORT_MS);
|
||||
offen.set(id, { fertig, timer });
|
||||
res.write(`data: ${JSON.stringify({ id, ...b })}\n\n`);
|
||||
});
|
||||
},
|
||||
|
||||
verbunden: () => leitung !== null,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user