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,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Daemon</title>
<style>
html, body { margin: 0; padding: 0; background: #0a0e0a; overflow: hidden; color: #5fdc62;
font: 14px/1.45 'DejaVu Sans Mono', 'Consolas', monospace; }
#status { padding: 12px 14px; }
#status h1 { font-size: 11px; letter-spacing: 0.25em; color: #2c8d2f; margin: 0 0 8px; font-weight: 700; }
#status .row { display: flex; gap: 10px; margin: 2px 0; }
#status .k { color: #2c8d2f; min-width: 70px; }
#status .v { color: #97f99a; word-break: break-all; }
#status .err { color: #ff6f5a; }
#status .ok { color: #97f99a; }
</style>
</head>
<body>
<div id="status">
<h1>OPHI MUSIC DAEMON</h1>
<div class="row"><span class="k">ws</span><span class="v" id="s-ws">init…</span></div>
<div class="row"><span class="k">queue</span><span class="v" id="s-queue"></span></div>
<div class="row"><span class="k">now</span><span class="v" id="s-now"></span></div>
<div class="row"><span class="k">audio</span><span class="v" id="s-audio"></span></div>
<div class="row"><span class="k">last cmd</span><span class="v" id="s-cmd"></span></div>
</div>
<audio id="player" preload="auto"></audio>
@include('partials.obs-ws-scripts')
<script src="{{ asset('data/playlist.js') }}"></script>
<script>
'use strict';
const data = window.__PLAYLIST || { tracks: [] };
const allTracks = data.tracks || [];
const audio = document.getElementById('player');
audio.volume = 1.0;
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const queue = shuffle([...allTracks]);
let qIdx = 0;
let stallTimer = null;
let _watchT = 0, _watchAt = Date.now();
const $ws = document.getElementById('s-ws');
const $q = document.getElementById('s-queue');
const $n = document.getElementById('s-now');
const $a = document.getElementById('s-audio');
const $c = document.getElementById('s-cmd');
function setWs(text, cls = '') { $ws.textContent = text; $ws.className = 'v ' + cls; }
function refreshStatus() {
$q.textContent = window.__PLAYLIST
? `${allTracks.length} tracks (playlist.js loaded)`
: 'playlist.js NOT loaded';
const t = allTracks.length ? `#${qIdx + 1}/${allTracks.length} — ${queue[qIdx]?.title || '?'}` : '—';
$n.textContent = t;
let aState = 'idle';
if (audio.paused) aState = 'paused';
else if (audio.ended) aState = 'ended';
else if (audio.src) aState = `playing (${audio.currentTime.toFixed(0)}s / ${(audio.duration||0).toFixed(0)}s)`;
$a.textContent = aState;
}
setInterval(refreshStatus, 500);
refreshStatus();
function clearStall() { clearTimeout(stallTimer); stallTimer = null; }
let obs = null;
async function connectOBS() {
if (!window.__OBSWS) {
setWs('public/js/obs-config.js MISSING — run `php artisan rig:setup`', 'err');
return;
}
try {
setWs(`connecting → ${window.__OBSWS.url}`);
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
setWs('identified ✓', 'ok');
obs.addEventListener('close', () => {
setWs('closed — retrying in 2s', 'err');
setTimeout(connectOBS, 2000);
});
obs.onCustom('mpd:cmd', (d) => handleCommand(d));
broadcastState();
} catch (e) {
setWs(`connect failed (${e.message}) — retrying`, 'err');
setTimeout(connectOBS, 2500);
}
}
connectOBS();
function broadcastState() {
if (!obs || !obs.identified) return;
const t = queue[qIdx];
obs.broadcast('mpd:state', {
index: qIdx,
total: queue.length,
title: t?.title || null,
file: t?.file || null,
currentTime: audio.currentTime || 0,
duration: audio.duration || t?.durationSec || 0,
paused: audio.paused,
}).catch(() => {});
}
function playCurrent() {
if (queue.length === 0) return;
qIdx = ((qIdx % queue.length) + queue.length) % queue.length;
const t = queue[qIdx];
clearStall();
audio.src = t.file;
stallTimer = setTimeout(() => {
console.warn('[music] start timeout, skipping', t.title);
advance(+1);
}, 15000);
audio.play().catch(() => {
clearStall();
console.warn('[music] play() rejected, skipping', t.title);
advance(+1);
});
broadcastState();
}
function advance(direction) {
clearStall();
if (queue.length === 0) return;
qIdx = (qIdx + direction + queue.length) % queue.length;
playCurrent();
}
audio.addEventListener('ended', () => advance(+1));
audio.addEventListener('error', () => { console.warn('[music] decode error'); advance(+1); });
audio.addEventListener('playing', clearStall);
setInterval(() => {
if (audio.paused || audio.ended || !audio.src) {
_watchT = audio.currentTime;
_watchAt = Date.now();
return;
}
if (Math.abs(audio.currentTime - _watchT) >= 0.05) {
_watchT = audio.currentTime;
_watchAt = Date.now();
} else if (Date.now() - _watchAt > 8000) {
console.warn('[music] mid-track stall, skipping');
_watchT = 0; _watchAt = Date.now();
advance(+1);
}
}, 1000);
setInterval(broadcastState, 1000);
// Only command source now: OBS WS `mpd:cmd`. The Twitch bot and the
// Laravel `POST /cmd/{type}` route both broadcast through this same event.
function handleCommand(cmd) {
const type = (cmd?.type || '').toLowerCase();
$c.textContent = `${type} @ ${new Date().toLocaleTimeString()}`;
switch (type) {
case 'skip': advance(+1); break;
case 'prev': advance(-1); break;
case 'pause': audio.pause(); broadcastState(); break;
case 'resume': audio.play().catch(() => {}); broadcastState(); break;
default:
console.warn('[music] unknown command:', type);
}
}
if (queue.length > 0) {
playCurrent();
} else {
console.warn('[music] no tracks — run `php artisan rig:playlist`');
}
</script>
</body>
</html>