Replaces the per-scene HTML directories (landing/, loading/, game/,
desktop/, goodbye/, music-box/, music/) with a single Laravel app
serving every overlay over HTTP. Supervisord runs `php artisan serve`
on 127.0.0.1:1118 and the OBS scene JSON now references HTTP routes
instead of file:// URLs.
Highlights:
- public/css/hud.css consolidates the duplicated HUD chrome,
scanlines/vignette/flicker, terminal styling, and pulse keyframes
that were copy-pasted across all seven scenes.
- Blade partials own hud-strip, crt-overlays, obs-ws-scripts,
camera-frame, screen-frame; the seven scenes extend a shared
overlay layout.
- Artisan commands (`rig:setup`, `rig:telemetry`, `rig:loading`,
`rig:playlist`, `rig:cmd`) replace the shell scripts that wrote
per-rig JSON snapshots. TwitchHelix + HardwareSnapshot services
handle the work the bash + Python helpers used to.
- ObsWsClient + MusicCommandController kill the 250 ms cmd.js poll
in the music daemon: POST /cmd/{skip|prev|pause|resume} opens a
short-lived Pawl WS, authenticates, and broadcasts mpd:cmd.
- AudioController streams files from the configured music dirs so
CEF can load tracks under the HTTP origin (Chromium blocks
HTTP-origin pages from loading file:// media).
- DataController serves /data/playlist.js (with ETag mtime cache)
and /cover.jpg (no-store) so the existing overlays' window.__PLAYLIST
and cover.jpg cache-bust pattern keeps working.
scripts/obs-webapp.supervisord.conf is the supervisord unit; install
to /etc/supervisor.d/obs-webapp.conf.
scripts/rewrite-scene-urls.py is a one-shot tool that rewrites
basic/scenes/Default_Stream_HUD.json from file:// to HTTP URLs.
basic/scenes/Default_Stream_HUD.json.pre-webapp is the rollback
artifact (made with OBS closed; full pre-migration state).
The seven old scene directories, vendor/, and the bash scripts are
still on disk pending visual verification; the next commit will
prune them.
238 lines
9.6 KiB
JavaScript
238 lines
9.6 KiB
JavaScript
// Minimal OBS WebSocket v5 client (JSON variant).
|
|
// Just what the music daemon + loading overlay need:
|
|
// - connect with optional auth (HMAC-SHA256 challenge)
|
|
// - call('BroadcastCustomEvent', { eventData: { ... } })
|
|
// - onCustom(cb) → fires for incoming CustomEvent broadcasts
|
|
//
|
|
// Protocol reference:
|
|
// https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
|
//
|
|
// Exposes: window.OBSWSMini
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
// Pure-JS SHA-256 (FIPS 180-4) → base64. Used when crypto.subtle is
|
|
// unavailable — e.g. OBS's CEF browser source, where file:// URLs do
|
|
// not grant secure-context status, so window.crypto.subtle is undefined.
|
|
// Input is treated as a JS string and encoded UTF-8 before hashing.
|
|
function _sha256b64Pure(str) {
|
|
const bytes = [];
|
|
for (let i = 0; i < str.length; i++) {
|
|
let c = str.charCodeAt(i);
|
|
if (c < 0x80) bytes.push(c);
|
|
else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
|
else if (c < 0xd800 || c >= 0xe000) {
|
|
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
|
} else {
|
|
i++;
|
|
c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
|
|
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f),
|
|
0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
|
}
|
|
}
|
|
const bitLen = bytes.length * 8;
|
|
bytes.push(0x80);
|
|
while (bytes.length % 64 !== 56) bytes.push(0);
|
|
const high = Math.floor(bitLen / 0x100000000);
|
|
const low = bitLen >>> 0;
|
|
for (let i = 3; i >= 0; i--) bytes.push((high >>> (i * 8)) & 0xff);
|
|
for (let i = 3; i >= 0; i--) bytes.push((low >>> (i * 8)) & 0xff);
|
|
|
|
const H = new Uint32Array([
|
|
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
|
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
]);
|
|
const K = [
|
|
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
|
];
|
|
const ROTR = (x, n) => (x >>> n) | (x << (32 - n));
|
|
const W = new Uint32Array(64);
|
|
for (let block = 0; block < bytes.length; block += 64) {
|
|
for (let i = 0; i < 16; i++) {
|
|
W[i] = ((bytes[block + i*4] << 24) |
|
|
(bytes[block + i*4+1] << 16) |
|
|
(bytes[block + i*4+2] << 8) |
|
|
bytes[block + i*4+3]) >>> 0;
|
|
}
|
|
for (let i = 16; i < 64; i++) {
|
|
const s0 = ROTR(W[i-15], 7) ^ ROTR(W[i-15], 18) ^ (W[i-15] >>> 3);
|
|
const s1 = ROTR(W[i-2], 17) ^ ROTR(W[i-2], 19) ^ (W[i-2] >>> 10);
|
|
W[i] = (W[i-16] + s0 + W[i-7] + s1) >>> 0;
|
|
}
|
|
let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
|
|
for (let i = 0; i < 64; i++) {
|
|
const S1 = ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25);
|
|
const ch = (e & f) ^ (~e & g);
|
|
const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
|
|
const S0 = ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22);
|
|
const mj = (a & b) ^ (a & c) ^ (b & c);
|
|
const t2 = (S0 + mj) >>> 0;
|
|
h = g; g = f; f = e; e = (d + t1) >>> 0;
|
|
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
}
|
|
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
|
|
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
|
|
}
|
|
let bin = '';
|
|
for (let i = 0; i < 8; i++) {
|
|
bin += String.fromCharCode((H[i]>>>24)&0xff, (H[i]>>>16)&0xff, (H[i]>>>8)&0xff, H[i]&0xff);
|
|
}
|
|
return btoa(bin);
|
|
}
|
|
|
|
class OBSWSMini extends EventTarget {
|
|
constructor(url, password) {
|
|
super();
|
|
this.url = url;
|
|
this.password = password || '';
|
|
this._reqId = 0;
|
|
this._pending = new Map();
|
|
this.ws = null;
|
|
this.identified = false;
|
|
}
|
|
|
|
async _sha256b64(str) {
|
|
// Prefer WebCrypto when available (HTTPS/localhost contexts).
|
|
if (typeof crypto !== 'undefined' && crypto.subtle && crypto.subtle.digest) {
|
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
|
const bytes = new Uint8Array(buf);
|
|
let bin = '';
|
|
for (const b of bytes) bin += String.fromCharCode(b);
|
|
return btoa(bin);
|
|
}
|
|
return _sha256b64Pure(str);
|
|
}
|
|
|
|
async _authString(salt, challenge) {
|
|
const secret = await this._sha256b64(this.password + salt);
|
|
return await this._sha256b64(secret + challenge);
|
|
}
|
|
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
let identified = false;
|
|
let settled = false;
|
|
const settle = (fn, val) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
fn(val);
|
|
};
|
|
|
|
// Hard timeout — if the server never sends Identified within 10s,
|
|
// give up so the caller can show an error instead of hanging.
|
|
const timeout = setTimeout(() => {
|
|
if (!identified) {
|
|
try { this.ws?.close(); } catch {}
|
|
settle(reject, new Error('connect timeout (no Identified within 10s)'));
|
|
}
|
|
}, 10000);
|
|
|
|
this.ws = new WebSocket(this.url);
|
|
|
|
this.ws.onopen = () => console.log('[OBSWS] socket open →', this.url);
|
|
this.ws.onerror = () => settle(reject, new Error('ws error'));
|
|
this.ws.onclose = (ev) => {
|
|
this.identified = false;
|
|
console.log(`[OBSWS] socket closed (code=${ev.code} reason='${ev.reason || ''}' clean=${ev.wasClean})`);
|
|
if (!identified) {
|
|
settle(reject, new Error(`closed before Identified (code=${ev.code} reason='${ev.reason || 'none'}')`));
|
|
}
|
|
this.dispatchEvent(new Event('close'));
|
|
};
|
|
|
|
this.ws.onmessage = async (ev) => {
|
|
let m;
|
|
try { m = JSON.parse(ev.data); } catch { return; }
|
|
console.log('[OBSWS] ←', m.op, m.d?.eventType || m.d?.requestType || '');
|
|
|
|
try {
|
|
if (m.op === 0) { // Hello
|
|
let auth;
|
|
if (m.d.authentication) {
|
|
if (!this.password) {
|
|
return settle(reject, new Error('server requires password'));
|
|
}
|
|
auth = await this._authString(m.d.authentication.salt, m.d.authentication.challenge);
|
|
}
|
|
const identifyMsg = {
|
|
op: 1,
|
|
d: { rpcVersion: 1, authentication: auth, eventSubscriptions: 0xFFFFFFFF },
|
|
};
|
|
console.log('[OBSWS] → 1 (Identify, auth-len=' + (auth?.length || 0) + ')');
|
|
this.ws.send(JSON.stringify(identifyMsg));
|
|
} else if (m.op === 2) { // Identified
|
|
identified = true;
|
|
this.identified = true;
|
|
settle(resolve, this);
|
|
this.dispatchEvent(new Event('identified'));
|
|
} else if (m.op === 5) { // Event
|
|
this.dispatchEvent(new CustomEvent('event', { detail: m.d }));
|
|
if (m.d.eventType === 'CustomEvent') {
|
|
this.dispatchEvent(new CustomEvent('custom', { detail: m.d.eventData || {} }));
|
|
}
|
|
} else if (m.op === 7) { // RequestResponse
|
|
const p = this._pending.get(m.d.requestId);
|
|
if (!p) return;
|
|
this._pending.delete(m.d.requestId);
|
|
if (m.d.requestStatus && m.d.requestStatus.result) {
|
|
p.resolve(m.d.responseData || {});
|
|
} else {
|
|
p.reject(new Error(m.d.requestStatus?.comment || 'request failed'));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[OBSWS] message handler error:', e);
|
|
settle(reject, new Error(`message handler error: ${e.message}`));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
call(requestType, requestData) {
|
|
if (!this.identified) return Promise.reject(new Error('not identified'));
|
|
const requestId = `r_${++this._reqId}`;
|
|
return new Promise((resolve, reject) => {
|
|
this._pending.set(requestId, { resolve, reject });
|
|
this.ws.send(JSON.stringify({
|
|
op: 6,
|
|
d: { requestType, requestId, requestData: requestData || {} },
|
|
}));
|
|
setTimeout(() => {
|
|
if (this._pending.has(requestId)) {
|
|
this._pending.delete(requestId);
|
|
reject(new Error('request timeout'));
|
|
}
|
|
}, 5000);
|
|
});
|
|
}
|
|
|
|
// Convenience: broadcast a namespaced custom event to all OBS WS clients.
|
|
broadcast(eventType, payload) {
|
|
return this.call('BroadcastCustomEvent', {
|
|
eventData: Object.assign({ _type: eventType }, payload || {}),
|
|
});
|
|
}
|
|
|
|
// Convenience: subscribe to incoming custom events. Filtered by _type if given.
|
|
onCustom(typeOrCb, cb) {
|
|
const filter = typeof typeOrCb === 'string' ? typeOrCb : null;
|
|
const fn = filter ? cb : typeOrCb;
|
|
this.addEventListener('custom', (e) => {
|
|
const d = e.detail || {};
|
|
if (filter && d._type !== filter) return;
|
|
fn(d);
|
|
});
|
|
}
|
|
}
|
|
|
|
root.OBSWSMini = OBSWSMini;
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|