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

+69
View File
@@ -0,0 +1,69 @@
// Die Leitung zum Hub, Seite des Stream-PCs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { ObsClient } from '#obs/client.ts';
import { befehlAus, sseZerleger, standLesen } from './fern.ts';
test('SSE: Nachrichten, die zerteilt oder zu zweit ankommen', () => {
const z = sseZerleger();
assert.deepEqual(z('retry: 2000\n\n'), [], 'retry allein ist keine Nachricht');
assert.deepEqual(z('data: {"a"'), []);
assert.deepEqual(z(':1}\n\ndata: 2\n\nda'), ['{"a":1}', '2']);
assert.deepEqual(z('ta: 3\r\n\r\n'), ['3'], 'auch mit Windows-Zeilenenden');
});
test('nur bekannte Befehle mit ID werden ausgefuehrt', () => {
assert.deepEqual(befehlAus({ id: 'x', art: 'szene', szene: 'Pause' }), { id: 'x', art: 'szene', szene: 'Pause' });
assert.deepEqual(befehlAus({ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true }),
{ id: 'x', art: 'stumm', quelle: 'Mikro', stumm: true });
assert.equal(befehlAus({ art: 'clip' }), null, 'ohne ID gibt es niemanden, dem man antwortet');
assert.equal(befehlAus({ id: 'x', art: 'beat' }), null);
assert.equal(befehlAus({ id: 'x', art: 'StopStream' }), null);
});
function falschesObs(antworten: Record<string, (d: Record<string, unknown>) => unknown>): ObsClient {
return {
run: async () => {},
isConnected: () => true,
request: async <T>(type: string, data?: unknown): Promise<T> => {
const f = antworten[type];
if (!f) throw new Error(`OBS lehnt ab (604): ${type}`);
return f((data ?? {}) as Record<string, unknown>) as T;
},
};
}
test('Stand: Szenen in OBS-Reihenfolge, nur Eingaenge mit Ton', async () => {
const obs = falschesObs({
GetSceneList: () => ({
currentProgramSceneName: 'Gameplay',
// OBS zaehlt von unten: Index 0 steht im Fenster ganz unten.
scenes: [{ sceneName: 'Pause', sceneIndex: 0 }, { sceneName: 'Gameplay', sceneIndex: 2 },
{ sceneName: 'Just Chatting', sceneIndex: 1 }],
}),
GetInputList: () => ({ inputs: [{ inputName: 'Mikro' }, { inputName: 'Facecam' }, { inputName: 'Desktop' }] }),
GetInputMute: (d) => {
if (d['inputName'] === 'Facecam') throw new Error('kein Ton');
return { inputMuted: d['inputName'] === 'Desktop' };
},
GetReplayBufferStatus: () => ({ outputActive: true }),
});
assert.deepEqual(await standLesen(obs), {
obs: true,
szenen: ['Gameplay', 'Just Chatting', 'Pause'],
aktuell: 'Gameplay',
ton: [{ name: 'Mikro', stumm: false }, { name: 'Desktop', stumm: true }],
puffer: true,
});
});
test('Stand ohne Wiedergabepuffer: kein Clip-Knopf, aber Szenen', async () => {
const obs = falschesObs({
GetSceneList: () => ({ currentProgramSceneName: 'A', scenes: [{ sceneName: 'A', sceneIndex: 0 }] }),
GetInputList: () => ({ inputs: [] }),
});
const s = await standLesen(obs);
assert.equal(s.puffer, false);
assert.deepEqual(s.szenen, ['A']);
});