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:
196
webapp/resources/views/overlays/music/widget.blade.php
Normal file
196
webapp/resources/views/overlays/music/widget.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user