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>
156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
// 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,
|
|
};
|
|
}
|