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:
195
webapp/resources/views/overlays/music/box.blade.php
Normal file
195
webapp/resources/views/overlays/music/box.blade.php
Normal file
@@ -0,0 +1,195 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'MUSIC BOX')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
body.overlay-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
gap: 22px;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.terminal {
|
||||
width: 64vw;
|
||||
max-width: 1480px;
|
||||
height: 60vh;
|
||||
max-height: 760px;
|
||||
min-height: 420px;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@push('data-scripts')
|
||||
<script>window.__TRACK_COUNT = @json($trackCount);</script>
|
||||
<script src="{{ asset('data/playlist.js') }}"></script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@include('partials.hud-strip', [
|
||||
'variant' => 'onair',
|
||||
'label' => 'ON AIR',
|
||||
'idText' => 'OPHI-118 // MUSIC',
|
||||
'signalGlyph' => '▮▮▮▮▯',
|
||||
'signalProfile' => 'strong',
|
||||
])
|
||||
|
||||
<main class="stage">
|
||||
<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 to daemon…</span><span class="np-time">[--:-- / --:--]</span></div></div></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="status-strip">
|
||||
<span class="label">CHAT</span>
|
||||
<span class="cmd-group">
|
||||
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
|
||||
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
|
||||
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class="banner banner--green">
|
||||
<div class="label">— TRANSMISSION OPEN —</div>
|
||||
<div class="title">MUSIC BOX</div>
|
||||
<div class="sub">— STAY TUNED —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— LOADING PLAYLIST —</span>
|
||||
<span class="onair">■ ON AIR</span>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const pad = HUD.pad;
|
||||
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0);
|
||||
document.getElementById('rig').textContent =
|
||||
trackCount > 0 ? `LIBRARY :: ${trackCount} TRACKS` : '— EMPTY LIBRARY —';
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
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 PROMPT = 'OPHI-118://> ';
|
||||
const MAX_TERM_LINES = 200;
|
||||
|
||||
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();
|
||||
}
|
||||
async function typeCommand(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const p = document.createElement('span');
|
||||
p.className = 'term-prompt';
|
||||
p.textContent = PROMPT;
|
||||
div.appendChild(p);
|
||||
const body = document.createElement('span');
|
||||
body.className = 'term-out';
|
||||
div.appendChild(body);
|
||||
term.insertBefore(div, pinnedBlock);
|
||||
for (const c of text) {
|
||||
body.textContent += c;
|
||||
await sleep(38);
|
||||
}
|
||||
await sleep(280);
|
||||
}
|
||||
function fmtClock(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
|
||||
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 = '[--:-- / --:--]';
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
OBSWSBootstrap.onState(applyState, {
|
||||
onClose: () => {
|
||||
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
|
||||
setOffline('daemon disconnected');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
await typeCommand('music --boot --shuffle');
|
||||
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
|
||||
await sleep(220);
|
||||
if (trackCount > 0) {
|
||||
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
|
||||
} else {
|
||||
logBeforePins('[audio] library manifest empty', 'term-dim');
|
||||
}
|
||||
await sleep(180);
|
||||
logBeforePins('[audio] queue: shuffle on', 'term-out');
|
||||
await sleep(180);
|
||||
logBeforePins('[audio] standby for transmission ✓', 'term-out');
|
||||
await sleep(280);
|
||||
startMusic();
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user