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:
148
webapp/resources/views/overlays/desktop.blade.php
Normal file
148
webapp/resources/views/overlays/desktop.blade.php
Normal file
@@ -0,0 +1,148 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'DESKTOP')
|
||||
|
||||
@section('body-class', 'camera-hud-body')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
:root {
|
||||
--cam-x: 1937px; --cam-y: 98px; --cam-w: 549px; --cam-h: 309px; --cam-pad: 10px;
|
||||
--scr-x: 10px; --scr-y: 14px; --scr-w: 1976px; --scr-h: 1209px; --scr-pad: 0px;
|
||||
}
|
||||
#signal { transform: translateY(-0.10em); }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('crt')
|
||||
{{-- transparent overlay — no CRT overlays --}}
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@include('partials.screen-frame', ['id' => 'scr', 'label' => 'SCR 01'])
|
||||
@include('partials.camera-frame', ['id' => 'cam', 'label' => 'CAM 01'])
|
||||
|
||||
<footer class="status-bar">
|
||||
<div class="status-line">
|
||||
<div class="live-block">
|
||||
<span class="live-dot"></span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<span class="sep">·</span>
|
||||
<span class="scene-name">OPHI-118 // DESKTOP</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="hud-tag"><span class="dim">SIGNAL</span><span id="signal" data-profile="normal">▮▮▮▯▯</span></span>
|
||||
<span class="sep">·</span>
|
||||
<span class="hud-tag"><span class="dim">UTC</span><span id="clock">--:--:--</span></span>
|
||||
</div>
|
||||
<div class="status-line meta-line">
|
||||
<span class="icon">◷</span>
|
||||
<span class="elapsed" id="elapsed">00:00:00</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="icon" title="microphone">🎙</span>
|
||||
<span style="color: var(--term-fg);">ON</span>
|
||||
</div>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const pad = HUD.pad;
|
||||
|
||||
const streamStart = Date.now();
|
||||
function tickElapsed() {
|
||||
const s = Math.floor((Date.now() - streamStart) / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const mm = Math.floor((s / 60) % 60);
|
||||
const ss = s % 60;
|
||||
document.getElementById('elapsed').textContent = `${pad(h)}:${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
tickElapsed(); setInterval(tickElapsed, 1000);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const CAM_SOURCE = params.get('camera') || 'Camera';
|
||||
const SCR_SOURCE = params.get('screen') || 'Screen Capture (PipeWire)';
|
||||
const SCENE_LOCK = params.get('scene') || null;
|
||||
|
||||
const camEl = document.getElementById('cam');
|
||||
const scrEl = document.getElementById('scr');
|
||||
const rootStyle = document.documentElement.style;
|
||||
|
||||
function renderedSize(t) {
|
||||
const useBounds = t.boundsType
|
||||
&& t.boundsType !== 'OBS_BOUNDS_NONE'
|
||||
&& (t.boundsWidth ?? 0) > 0;
|
||||
if (useBounds) return { w: t.boundsWidth, h: t.boundsHeight };
|
||||
return {
|
||||
w: (t.sourceWidth ?? 0) * (t.scaleX ?? 1),
|
||||
h: (t.sourceHeight ?? 0) * (t.scaleY ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFrame(obs, sourceName, frameEl, varPrefix) {
|
||||
const state = { sceneName: null, itemId: null };
|
||||
const setVisible = (v) => frameEl.classList.toggle('off', !v);
|
||||
const setTransform = (t) => {
|
||||
if (!t) return;
|
||||
const { w, h } = renderedSize(t);
|
||||
if (!(w > 0 && h > 0)) return;
|
||||
rootStyle.setProperty(`--${varPrefix}-x`, `${t.positionX}px`);
|
||||
rootStyle.setProperty(`--${varPrefix}-y`, `${t.positionY}px`);
|
||||
rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`);
|
||||
rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`);
|
||||
};
|
||||
return {
|
||||
async rebind(sceneName) {
|
||||
state.sceneName = sceneName;
|
||||
state.itemId = null;
|
||||
if (!sceneName) { setVisible(false); return; }
|
||||
try {
|
||||
const r = await obs.call('GetSceneItemId', { sceneName, sourceName });
|
||||
state.itemId = r.sceneItemId;
|
||||
const e = await obs.call('GetSceneItemEnabled', { sceneName, sceneItemId: state.itemId });
|
||||
setVisible(e.sceneItemEnabled);
|
||||
const t = await obs.call('GetSceneItemTransform', { sceneName, sceneItemId: state.itemId });
|
||||
setTransform(t.sceneItemTransform);
|
||||
} catch {
|
||||
setVisible(false);
|
||||
}
|
||||
},
|
||||
handleEvent(d) {
|
||||
if (d.eventData?.sceneName !== state.sceneName
|
||||
|| d.eventData?.sceneItemId !== state.itemId) return;
|
||||
if (d.eventType === 'SceneItemEnableStateChanged') {
|
||||
setVisible(d.eventData.sceneItemEnabled);
|
||||
} else if (d.eventType === 'SceneItemTransformChanged') {
|
||||
setTransform(d.eventData.sceneItemTransform);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
OBSWSBootstrap.connect({
|
||||
onConnect: async (obs) => {
|
||||
const camFrame = makeFrame(obs, CAM_SOURCE, camEl, 'cam');
|
||||
const scrFrame = makeFrame(obs, SCR_SOURCE, scrEl, 'scr');
|
||||
let scene = SCENE_LOCK;
|
||||
if (!scene) {
|
||||
try {
|
||||
const r = await obs.call('GetCurrentProgramScene');
|
||||
scene = r.currentProgramSceneName || r.sceneName || null;
|
||||
} catch { /* leave null */ }
|
||||
}
|
||||
await Promise.all([camFrame.rebind(scene), scrFrame.rebind(scene)]);
|
||||
obs.addEventListener('event', async (ev) => {
|
||||
const d = ev.detail || {};
|
||||
if (!SCENE_LOCK && d.eventType === 'CurrentProgramSceneChanged') {
|
||||
const next = d.eventData?.sceneName;
|
||||
await Promise.all([camFrame.rebind(next), scrFrame.rebind(next)]);
|
||||
return;
|
||||
}
|
||||
camFrame.handleEvent(d);
|
||||
scrFrame.handleEvent(d);
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user