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:
186
webapp/resources/views/overlays/music/cover.blade.php
Normal file
186
webapp/resources/views/overlays/music/cover.blade.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / Music Box cover</title>
|
||||
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
|
||||
<style>
|
||||
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;
|
||||
z-index: 4;
|
||||
}
|
||||
.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; }
|
||||
.art-stage {
|
||||
position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden;
|
||||
background: #02080a;
|
||||
}
|
||||
#cover {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
#cover.on { opacity: 1; }
|
||||
.placeholder {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 14px; text-align: center; padding: 0 22px;
|
||||
color: var(--term-fg-dim);
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
.placeholder.off { opacity: 0; }
|
||||
.placeholder svg { opacity: 0.55; }
|
||||
.placeholder .ph-text { font-size: 13px; letter-spacing: 0.42em; color: rgba(95, 220, 98, 0.65); }
|
||||
.placeholder .ph-sub { font-size: 11px; letter-spacing: 0.18em; color: var(--term-fg-dim); }
|
||||
.scanlines { opacity: 0.32; z-index: 3; }
|
||||
.caption {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 22px 12px;
|
||||
font-size: 14px;
|
||||
border-top: 1px solid rgba(95, 220, 98, 0.18);
|
||||
color: var(--term-fg);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
display: flex; gap: 10px; align-items: baseline;
|
||||
}
|
||||
.caption .arrow {
|
||||
color: var(--term-fg-bright);
|
||||
flex: 0 0 auto;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.caption .text {
|
||||
color: var(--term-fg-bright);
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.caption.empty .arrow,
|
||||
.caption.empty .text { color: var(--term-fg-dim); }
|
||||
body.no-bars .header,
|
||||
body.no-bars .caption { display: none; }
|
||||
</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">ALBUM ART</span>
|
||||
<span class="right">CH 118.0 // MUSIC</span>
|
||||
</div>
|
||||
|
||||
<div class="art-stage">
|
||||
<img id="cover" alt="">
|
||||
<div class="placeholder" id="placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="6" width="52" height="52" rx="2" stroke="#5fdc62" stroke-width="2"/>
|
||||
<circle cx="32" cy="32" r="14" stroke="#5fdc62" stroke-width="2"/>
|
||||
<circle cx="32" cy="32" r="3" fill="#5fdc62"/>
|
||||
</svg>
|
||||
<span class="ph-text">— NO ART —</span>
|
||||
<span class="ph-sub">awaiting cover…</span>
|
||||
</div>
|
||||
<div class="scanlines"></div>
|
||||
</div>
|
||||
|
||||
<div class="caption empty" id="caption">
|
||||
<span class="arrow">▶</span><span class="text" id="captionText">connecting…</span>
|
||||
</div>
|
||||
|
||||
@include('partials.obs-ws-scripts')
|
||||
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('bars') === '0') document.body.classList.add('no-bars');
|
||||
|
||||
const coverImg = document.getElementById('cover');
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
const caption = document.getElementById('caption');
|
||||
const captionText = document.getElementById('captionText');
|
||||
|
||||
// Cover served by Laravel — same origin as the page, so a relative
|
||||
// /cover.jpg works without CORS concerns. Cache-busted with ?v=<hash>.
|
||||
const COVER_URL = '/cover.jpg';
|
||||
|
||||
let lastHash = undefined;
|
||||
function showArt(_path, hash) {
|
||||
if (hash === lastHash) return;
|
||||
lastHash = hash;
|
||||
if (!hash) {
|
||||
coverImg.classList.remove('on');
|
||||
placeholder.classList.remove('off');
|
||||
coverImg.removeAttribute('src');
|
||||
return;
|
||||
}
|
||||
coverImg.onload = () => {
|
||||
coverImg.classList.add('on');
|
||||
placeholder.classList.add('off');
|
||||
};
|
||||
coverImg.onerror = () => {
|
||||
coverImg.classList.remove('on');
|
||||
placeholder.classList.remove('off');
|
||||
};
|
||||
coverImg.src = `${COVER_URL}?v=${hash}`;
|
||||
}
|
||||
|
||||
function applyState(s) {
|
||||
showArt(s.coverPath, s.coverHash);
|
||||
if (s.title) {
|
||||
captionText.textContent = s.title;
|
||||
caption.classList.remove('empty');
|
||||
} else {
|
||||
captionText.textContent = '—';
|
||||
caption.classList.add('empty');
|
||||
}
|
||||
}
|
||||
function setOffline(msg) {
|
||||
captionText.textContent = msg;
|
||||
caption.classList.add('empty');
|
||||
coverImg.classList.remove('on');
|
||||
placeholder.classList.remove('off');
|
||||
coverImg.removeAttribute('src');
|
||||
lastHash = undefined;
|
||||
}
|
||||
|
||||
OBSWSBootstrap.onState(applyState, {
|
||||
onClose: () => setOffline('disconnected'),
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user