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,196 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Box widget</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
/* Widget is authored at 549×880 (bounds_type 0, no stretch). */
html, body {
background: var(--term-bg);
color: var(--term-fg);
font-family: var(--mono);
}
body {
position: relative;
display: flex;
flex-direction: column;
border: 1.5px solid var(--frame);
box-shadow:
0 0 18px var(--frame-glow),
inset 0 0 50px rgba(0, 30, 0, 0.55);
text-shadow: 0 0 4px var(--term-glow);
}
.corner {
position: absolute;
width: 14px; height: 14px;
border-color: var(--frame);
border-style: solid;
border-width: 0;
}
.corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; }
.corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; }
.corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; }
.corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 22px 10px;
font-size: 13px; letter-spacing: 0.22em;
color: var(--hud);
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
flex: 0 0 auto;
}
.header .left { display: inline-flex; gap: 10px; align-items: center; }
.header .right { color: var(--hud-dim); font-size: 13px; letter-spacing: 0.12em; }
.terminal {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 22px 16px;
font-size: 16px;
line-height: 1.5;
color: var(--term-fg);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.terminal::after { display: none; }
.scanlines { opacity: 0.45; }
.np-line { display: flex; gap: 10px; align-items: baseline; }
.np-line .np-arrow { flex: 0 0 auto; margin-right: 0; }
.np-line .np-title {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.np-line .np-time { flex: 0 0 auto; font-size: 14px; margin-left: 0; }
.next-line { display: flex; gap: 8px; align-items: baseline; font-size: 14px; }
.next-line .next-arrow { flex: 0 0 auto; }
.next-line .next-label { flex: 0 0 auto; }
.next-line .next-title {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.chat-line {
font-size: 13px;
color: var(--term-fg-dim);
letter-spacing: 0.06em;
border-top: 1px solid rgba(95, 220, 98, 0.18);
padding-top: 8px;
margin-top: 6px;
}
.chat-line .cmd { color: var(--term-fg-bright); }
.pin-spacer { height: 10px; }
</style>
</head>
<body>
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="header">
<span class="left">NOW PLAYING</span>
<span class="right">CH 118.0 // MUSIC</span>
</div>
<div class="terminal">
<div id="termOutput"><div id="pinnedBlock"><div class="pin-spacer"></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">next:</span><span class="next-title empty" data-slot="0"> end of queue </span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="1"></span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="2"></span></div><div class="pin-spacer"></div><div class="term-line np-line"><span class="np-arrow"></span><span class="np-title">connecting…</span><span class="np-time">[--:-- / --:--]</span></div><div class="term-line chat-line">chat: <span class="cmd">!skip</span> · <span class="cmd">!queue</span> · <span class="cmd">!info</span></div></div></div>
</div>
<div class="scanlines"></div>
@include('partials.obs-ws-scripts')
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
<script>
'use strict';
const pad = n => String(n).padStart(2, '0');
const fmtClock = s => (!isFinite(s) || s < 0)
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
const term = document.getElementById('termOutput');
const pinnedBlock = document.getElementById('pinnedBlock');
const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const MAX_TERM_LINES = 60;
function pruneTerminal() {
while (term.children.length > MAX_TERM_LINES) {
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
}
function logBeforePins(text, cls = 'term-out', prompt = '> ') {
const div = document.createElement('div');
div.className = 'term-line';
if (prompt) {
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = prompt;
div.appendChild(p);
}
const t = document.createElement('span');
if (cls) t.className = cls;
t.textContent = text;
div.appendChild(t);
term.insertBefore(div, pinnedBlock);
pruneTerminal();
}
let lastTrackFile = null;
function applyState(s) {
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile && s.title) {
logBeforePins(`[audio] now: ${s.title}`);
}
if (s.file) lastTrackFile = s.file;
npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
nextSlots.forEach((slot, i) => {
const v = titles[i];
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
} else {
slot.textContent = i === 0 ? '— end of queue —' : '—';
slot.classList.add('empty');
}
});
pruneTerminal();
}
function setOffline(msg) {
npTitle.textContent = msg;
npTime.textContent = '[--:-- / --:--]';
}
(async () => {
await new Promise(r => setTimeout(r, 200));
logBeforePins('[audio] subscribing to mpd:state', 'term-out');
await new Promise(r => setTimeout(r, 180));
logBeforePins('[audio] queue: shuffle on', 'term-out');
await new Promise(r => setTimeout(r, 180));
logBeforePins('[audio] standby ✓', 'term-out');
await new Promise(r => setTimeout(r, 220));
OBSWSBootstrap.onState(applyState, {
onClose: () => { logBeforePins('daemon disconnected', 'term-dim'); setOffline('disconnected'); },
});
})();
</script>
</body>
</html>