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:
71
webapp/app/Http/Controllers/AudioController.php
Normal file
71
webapp/app/Http/Controllers/AudioController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Streams audio files from the configured music_dirs via HTTP so the
|
||||
* music-daemon page (served on http://127.0.0.1:8000/) can load them.
|
||||
*
|
||||
* Chromium blocks HTTP-origin pages from loading `file://` media as a
|
||||
* mixed-protocol security policy, which is why the old file://-only
|
||||
* setup worked but the new HTTP setup needs this.
|
||||
*
|
||||
* Path-traversal guard: the resolved file's real path must sit inside
|
||||
* one of the configured roots; the request 404s otherwise.
|
||||
*/
|
||||
class AudioController extends Controller
|
||||
{
|
||||
public function stream(Request $request): Response
|
||||
{
|
||||
$b64 = (string) $request->query('p', '');
|
||||
if ($b64 === '') abort(400, 'missing p');
|
||||
|
||||
// URL-safe base64; pad back to a multiple of 4 for strict decoding.
|
||||
$padded = $b64 . str_repeat('=', (4 - strlen($b64) % 4) % 4);
|
||||
$path = base64_decode(strtr($padded, '-_', '+/'), true);
|
||||
if ($path === false || $path === '') abort(400, 'bad encoding');
|
||||
|
||||
$real = realpath($path);
|
||||
if ($real === false || !is_file($real)) abort(404, 'file not found: ' . $path);
|
||||
|
||||
$allowed = false;
|
||||
foreach ((array) config('rig.music_dirs') as $root) {
|
||||
$rootReal = realpath($root);
|
||||
if ($rootReal && str_starts_with($real . '/', rtrim($rootReal, '/') . '/')) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$allowed) abort(403, 'outside allowed roots');
|
||||
|
||||
$ext = strtolower(pathinfo($real, PATHINFO_EXTENSION));
|
||||
$mime = match ($ext) {
|
||||
'm4a', 'aac' => 'audio/mp4',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'opus', 'ogg'=> 'audio/ogg',
|
||||
'flac' => 'audio/flac',
|
||||
'webm' => 'audio/webm',
|
||||
'wav' => 'audio/wav',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
|
||||
// BinaryFileResponse handles Range requests automatically, which the
|
||||
// HTML5 <audio> element issues when seeking.
|
||||
$resp = new BinaryFileResponse($real, 200, [
|
||||
'Content-Type' => $mime,
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/** Encode an absolute path into the URL-safe base64 used by the {@see stream} route. */
|
||||
public static function urlFor(string $absolutePath): string
|
||||
{
|
||||
return '/track?p=' . rtrim(strtr(base64_encode($absolutePath), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
8
webapp/app/Http/Controllers/Controller.php
Normal file
8
webapp/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
78
webapp/app/Http/Controllers/DataController.php
Normal file
78
webapp/app/Http/Controllers/DataController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
20
webapp/app/Http/Controllers/MusicCommandController.php
Normal file
20
webapp/app/Http/Controllers/MusicCommandController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ObsWsClient;
|
||||
use Illuminate\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
class MusicCommandController extends Controller
|
||||
{
|
||||
public function send(string $type, ObsWsClient $ws): Response
|
||||
{
|
||||
try {
|
||||
$ws->broadcast('mpd:cmd', ['type' => $type]);
|
||||
} catch (Throwable $e) {
|
||||
return response($e->getMessage(), 502);
|
||||
}
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
13
webapp/app/Http/Controllers/Overlays/DesktopController.php
Normal file
13
webapp/app/Http/Controllers/Overlays/DesktopController.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class DesktopController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('overlays.desktop');
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/GameController.php
Normal file
16
webapp/app/Http/Controllers/Overlays/GameController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class GameController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.game', [
|
||||
'manifest' => $rig->loading(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/GoodbyeController.php
Normal file
16
webapp/app/Http/Controllers/Overlays/GoodbyeController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class GoodbyeController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.goodbye', [
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/LandingController.php
Normal file
16
webapp/app/Http/Controllers/Overlays/LandingController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class LandingController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.landing', [
|
||||
'telemetry' => $rig->telemetry(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
webapp/app/Http/Controllers/Overlays/LoadingController.php
Normal file
18
webapp/app/Http/Controllers/Overlays/LoadingController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class LoadingController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.loading', [
|
||||
'manifest' => $rig->loading(),
|
||||
'telemetry' => $rig->telemetry(),
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
webapp/app/Http/Controllers/Overlays/MusicBoxController.php
Normal file
33
webapp/app/Http/Controllers/Overlays/MusicBoxController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class MusicBoxController extends Controller
|
||||
{
|
||||
public function box(RigData $rig)
|
||||
{
|
||||
return view('overlays.music.box', [
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function widget()
|
||||
{
|
||||
return view('overlays.music.widget');
|
||||
}
|
||||
|
||||
public function cover()
|
||||
{
|
||||
return view('overlays.music.cover');
|
||||
}
|
||||
|
||||
public function nc(RigData $rig)
|
||||
{
|
||||
return view('overlays.music.nc', [
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class MusicDaemonController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('overlays.music.daemon');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user