webapp: introduce Laravel app under webapp/ for scene overlays

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.
This commit is contained in:
Jakub Zych
2026-05-21 12:47:10 +02:00
parent c8f9c11c93
commit 059f069ef4
85 changed files with 19180 additions and 49 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Support\RigData;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class DataController extends Controller
{
/**
* Serve the playlist as a script-tag-friendly JS global. Cached via ETag on file mtime
* so CEF can revalidate cheaply (the playlist file changes rarely but the body is ~142 KB).
*/
public function playlist(Request $request, RigData $rig)
{
$path = $rig->playlistPath();
$tracks = $rig->playlist();
$mtime = is_file($path) ? filemtime($path) : 0;
$etag = '"' . md5('playlist:' . $mtime) . '"';
if ($request->headers->get('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
// Rewrite `file://` URIs into `/audio?p=...` HTTP URLs so the
// music-daemon page (served over HTTP) can load tracks. Chromium
// blocks HTTP-origin pages from loading file:// media.
if (isset($tracks['tracks']) && is_array($tracks['tracks'])) {
foreach ($tracks['tracks'] as $i => $track) {
if (!isset($track['file'])) continue;
if (str_starts_with($track['file'], 'file://')) {
$absolute = rawurldecode(substr($track['file'], 7));
$tracks['tracks'][$i]['file'] = \App\Http\Controllers\AudioController::urlFor($absolute);
}
}
}
$body = 'window.__PLAYLIST = ' . json_encode(
$tracks,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
) . ';';
return response($body, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'public, must-revalidate',
'ETag' => $etag,
]);
}
/**
* Serve bridges/cover.jpg. `no-store` so CEF never holds a stale image
* even if a caller forgets the ?v=<hash> cache-bust. 404 returns a 1×1
* transparent PNG so the music-box cover placeholder doesn't get a
* broken-image icon while the bridge is starting up.
*/
public function coverImage(RigData $rig): SymfonyResponse
{
$path = $rig->coverPath();
if (is_file($path) && is_readable($path)) {
return response()->file($path, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'no-store, max-age=0',
]);
}
// 1x1 transparent PNG (67 bytes).
$png = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
);
return response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'no-store, max-age=0',
]);
}
}