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.
159 lines
6.5 KiB
PHP
159 lines
6.5 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use Exception;
|
||
use Ratchet\Client\Connector as PawlConnector;
|
||
use Ratchet\Client\WebSocket;
|
||
use Ratchet\RFC6455\Messaging\MessageInterface;
|
||
use React\EventLoop\Loop;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Short-lived OBS WebSocket v5 client used to broadcast `mpd:cmd` from the
|
||
* Laravel side (POST /cmd/{type} and `php artisan rig:cmd`).
|
||
*
|
||
* Mirrors the auth + protocol opcodes implemented in public/js/obs-ws-mini.js:
|
||
* - op 0 Hello → server sends auth challenge if password set
|
||
* - op 1 Identify → client sends auth string + event subscriptions
|
||
* - op 2 Identified → identification OK; we can now send Requests
|
||
* - op 6 Request → BroadcastCustomEvent with eventData = { _type, ...payload }
|
||
* - op 7 Response → request acknowledged
|
||
*
|
||
* Auth string: base64(SHA256(base64(SHA256(password + salt)) + challenge))
|
||
*
|
||
* Connect → identify → broadcast → close, all inside a single React event-loop
|
||
* tick. Total wall time on localhost is typically 30–80 ms.
|
||
*/
|
||
class ObsWsClient
|
||
{
|
||
private string $url;
|
||
private string $password;
|
||
private int $timeoutMs;
|
||
|
||
public function __construct(?string $url = null, ?string $password = null, int $timeoutMs = 5000)
|
||
{
|
||
$this->url = $url ?? (string) config('rig.obs_ws.url');
|
||
$this->password = $password ?? (string) config('rig.obs_ws.password');
|
||
$this->timeoutMs = $timeoutMs;
|
||
}
|
||
|
||
/**
|
||
* Broadcast a CustomEvent through OBS WS to every connected client.
|
||
* `_type` is added to eventData so the mini client's `onCustom(typeFilter)` works.
|
||
*/
|
||
public function broadcast(string $type, array $payload = []): void
|
||
{
|
||
$eventData = array_merge(['_type' => $type], $payload);
|
||
$this->sendRequest('BroadcastCustomEvent', ['eventData' => $eventData]);
|
||
}
|
||
|
||
public function sendRequest(string $requestType, array $requestData = []): array
|
||
{
|
||
$loop = Loop::get();
|
||
$connector = new PawlConnector($loop);
|
||
|
||
$result = null;
|
||
$error = null;
|
||
$identified = false;
|
||
$requestId = 'php-' . bin2hex(random_bytes(6));
|
||
|
||
$promise = $connector($this->url);
|
||
|
||
$promise->then(function (WebSocket $conn) use (&$result, &$error, &$identified, $requestId, $requestType, $requestData) {
|
||
$conn->on('message', function (MessageInterface $msg) use ($conn, &$result, &$error, &$identified, $requestId, $requestType, $requestData) {
|
||
$payload = json_decode((string) $msg, true);
|
||
if (!is_array($payload) || !isset($payload['op'])) return;
|
||
|
||
try {
|
||
switch ($payload['op']) {
|
||
case 0: // Hello
|
||
$auth = null;
|
||
if (isset($payload['d']['authentication'])) {
|
||
if ($this->password === '') {
|
||
$error = new Exception('OBS WS server requires password but none configured');
|
||
$conn->close();
|
||
return;
|
||
}
|
||
$auth = $this->authString(
|
||
$payload['d']['authentication']['salt'],
|
||
$payload['d']['authentication']['challenge']
|
||
);
|
||
}
|
||
$identify = [
|
||
'op' => 1,
|
||
'd' => [
|
||
'rpcVersion' => 1,
|
||
'authentication' => $auth,
|
||
'eventSubscriptions' => 0,
|
||
],
|
||
];
|
||
// null auth would serialize as JSON null; OBS WS expects the key absent when no auth.
|
||
if ($auth === null) unset($identify['d']['authentication']);
|
||
$conn->send(json_encode($identify));
|
||
break;
|
||
|
||
case 2: // Identified
|
||
$identified = true;
|
||
$conn->send(json_encode([
|
||
'op' => 6,
|
||
'd' => [
|
||
'requestType' => $requestType,
|
||
'requestId' => $requestId,
|
||
'requestData' => (object) $requestData,
|
||
],
|
||
]));
|
||
break;
|
||
|
||
case 7: // RequestResponse
|
||
if (($payload['d']['requestId'] ?? null) !== $requestId) return;
|
||
$status = $payload['d']['requestStatus'] ?? [];
|
||
if (!empty($status['result'])) {
|
||
$result = $payload['d']['responseData'] ?? [];
|
||
} else {
|
||
$error = new Exception(
|
||
'OBS WS request failed: ' . ($status['comment'] ?? 'unknown')
|
||
);
|
||
}
|
||
$conn->close();
|
||
break;
|
||
}
|
||
} catch (Throwable $e) {
|
||
$error = $e;
|
||
$conn->close();
|
||
}
|
||
});
|
||
|
||
$conn->on('close', function () {
|
||
Loop::stop();
|
||
});
|
||
$conn->on('error', function ($e) use (&$error) {
|
||
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
|
||
Loop::stop();
|
||
});
|
||
}, function ($e) use (&$error) {
|
||
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
|
||
Loop::stop();
|
||
});
|
||
|
||
// Hard timeout guard — Loop::stop fires no matter what.
|
||
$timer = $loop->addTimer($this->timeoutMs / 1000, function () use (&$error) {
|
||
$error = $error ?: new Exception('OBS WS request timed out');
|
||
Loop::stop();
|
||
});
|
||
|
||
$loop->run();
|
||
$loop->cancelTimer($timer);
|
||
|
||
if ($error) throw $error;
|
||
if (!$identified) throw new Exception('OBS WS never reached Identified');
|
||
return $result ?? [];
|
||
}
|
||
|
||
private function authString(string $salt, string $challenge): string
|
||
{
|
||
$secret = base64_encode(hash('sha256', $this->password . $salt, true));
|
||
return base64_encode(hash('sha256', $secret . $challenge, true));
|
||
}
|
||
}
|