webapp: archive pre-migration scene dirs + bash scripts, update docs

Moves the seven HTML scene dirs (landing/, loading/, game/, desktop/,
goodbye/, music-box/, music/) and the superseded bash helpers (setup.sh,
loading.sh, playlist.sh, telemetry.sh) into archived/ rather than deleting.
The Laravel webapp/ replaces all of them; archived/README.md spells out
the rollback procedure.

Also:
 - rewrite the relevant sections of CLAUDE.md so it points at webapp/
   blade views, the Artisan commands, and the supervisord lifecycle
   (`supervisorctl restart obs-webapp` after route / controller / .env
   changes; Blade view edits are still safe-while-running via OBS's
   Refresh cache).
 - extend scripts/deploy-rig.sh to install php + composer + supervisor,
   run `composer install`, copy obs-webapp.supervisord.conf into
   /etc/supervisor.d/, start the program, and call `php artisan
   rig:setup` + `rig:telemetry --collect` instead of the old bash.
 - .gitignore catches the generated machine-local files that came along
   when the old scene dirs moved (telemetry.js, loading.json, playlist.js,
   cmd.js, obs-config.js).
 - daemon.blade.php is now passive — listens to mpd:state but does not
   play audio or broadcast its own queue, so it stops fighting with
   bridges/mpd-state.py (the post-browser-daemon-migration source of
   truth for mpd:state).
 - nc.blade.php overrides .terminal { overflow: hidden } from hud.css
   so the `┤ TERMINAL ├` and `┤ NOW PLAYING ├` pane-tabs stick above
   the pane border instead of being clipped.
This commit is contained in:
Jakub Zych
2026-05-21 13:08:49 +02:00
parent 059f069ef4
commit 0b4f121a92
23 changed files with 229 additions and 156 deletions

View File

@@ -0,0 +1,431 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / GAME</title>
<style>
:root {
--ink: #e8e8e0;
--ink-dim: rgba(232, 232, 224, 0.62);
--hud: #4fd2ff;
--hud-dim: #2a7fa6;
--hud-edge: rgba(79, 210, 255, 0.85);
--hud-glow: rgba(79, 210, 255, 0.40);
--accent: #e63a2e;
--warn: #ffd000;
--term-fg: #5fdc62;
--term-fg-dim:#2c8d2f;
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
--pad-y: 40px;
--pad-x: 72px;
/* Camera frame transform — auto-synced from OBS at runtime via
GetSceneItemTransform + SceneItemTransformChanged. These defaults
are only the first-paint fallback; once the WS connects the box
snaps to wherever the Camera source actually is in the Game scene. */
--cam-x: 1855px;
--cam-y: 128px;
--cam-w: 580px;
--cam-h: 326px;
/* Visual outset around the camera so the cyan border + corner ticks
breathe and stay visible on all four sides (rather than getting
overdrawn by the camera image). Applied symmetrically. */
--cam-pad: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: transparent; /* overlay only — game capture shows through */
color: var(--ink);
font-family: var(--display);
overflow: hidden;
user-select: none;
}
/* ───────── Top HUD ───────── */
.hud {
position: absolute;
top: var(--pad-y);
left: var(--pad-x);
right: var(--pad-x);
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 20px;
font-weight: 700;
color: var(--hud);
letter-spacing: 0.08em;
text-shadow: 0 0 8px rgba(7, 8, 13, 0.9), 0 0 4px rgba(7, 8, 13, 0.9);
}
.hud .group { display: flex; gap: 24px; align-items: center; }
.hud > .group:nth-child(1) { justify-self: start; }
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 10px; }
.hud .led {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 1.6s ease-in-out infinite;
}
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) ≈ 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; }
}
/* ───────── Camera frame ─────────
Decorative chrome drawn UNDER the OBS Camera scene item. The four
--cam-* CSS vars are auto-synced from the Camera item's transform via
OBS WebSocket (see syncCamera below), so wherever you drag/resize the
camera in OBS, this frame snaps to match. The dark backdrop + centered
placeholder show through only until the WS connects or while the camera
is hidden. Requires the HUD browser source to render at canvas
resolution (2560×1336) so 1 CSS px == 1 canvas px. */
.camera-frame {
position: absolute;
top: calc(var(--cam-y) - var(--cam-pad));
left: calc(var(--cam-x) - var(--cam-pad));
width: calc(var(--cam-w) + var(--cam-pad) * 2);
height: calc(var(--cam-h) + var(--cam-pad) * 2);
pointer-events: none;
background: rgba(6, 10, 18, 0.85);
border: 1.5px solid rgba(79, 210, 255, 0.55);
box-shadow:
0 0 28px rgba(79, 210, 255, 0.20),
inset 0 0 60px rgba(0, 0, 0, 0.55);
}
.camera-frame .tick {
position: absolute;
width: 24px; height: 24px;
border: 3px solid var(--hud-edge);
box-shadow: 0 0 8px var(--hud-glow);
}
.camera-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
.camera-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
.camera-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
.camera-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
.camera-frame .label {
position: absolute;
top: 10px; left: 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--hud);
letter-spacing: 0.32em;
text-shadow: 0 0 4px rgba(7, 8, 13, 0.9);
}
.camera-frame .placeholder {
position: absolute; inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
font-family: var(--mono);
color: var(--hud);
pointer-events: none;
}
.camera-frame .placeholder svg { opacity: 0.6; }
.camera-frame .placeholder .ph-text {
font-size: 13px;
letter-spacing: 0.45em;
color: rgba(79, 210, 255, 0.7);
}
.camera-frame .placeholder .ph-coords {
font-size: 11px;
letter-spacing: 0.20em;
color: var(--hud-dim);
}
.camera-frame.off { display: none; }
/* ───────── Bottom status bar ───────── */
.status-bar {
position: absolute;
bottom: 0;
left: 0; right: 0;
padding: 22px var(--pad-x) 26px;
/* Solid darker base + cyan top-edge so the bar reads cleanly on any
gameplay background. Top fade is short so the strip feels grounded. */
background:
linear-gradient(
to top,
rgba(7, 8, 13, 0.96) 0%,
rgba(7, 8, 13, 0.96) 65%,
rgba(7, 8, 13, 0.50) 90%,
rgba(7, 8, 13, 0) 100%
);
border-top: 1px solid rgba(79, 210, 255, 0.28);
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.5);
font-family: var(--mono);
letter-spacing: 0.08em;
}
.status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.status-line + .status-line { margin-top: 8px; }
.status-line .sep { color: var(--hud-dim); }
.live-block {
display: flex; align-items: center; gap: 10px;
color: var(--accent);
font-weight: 700;
letter-spacing: 0.14em;
font-size: 18px;
}
.live-dot {
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
animation: rec-blink 1.6s ease-in-out infinite;
}
.game-name {
color: var(--ink);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.10em;
}
.game-mode {
color: var(--hud);
font-size: 17px;
letter-spacing: 0.18em;
}
.meta-line {
color: var(--ink-dim);
font-size: 16px;
}
.meta-line .icon { color: var(--hud); }
.np-block { display: flex; align-items: center; gap: 10px; visibility: hidden; }
.np-block.on { visibility: visible; }
.np-block .arrow {
color: var(--term-fg);
animation: np-pulse 1.05s ease-in-out infinite alternate;
text-shadow: 0 0 6px rgba(80, 220, 100, 0.4);
}
.np-block .title { color: var(--ink); }
.np-block .time { color: var(--ink-dim); }
@keyframes np-pulse {
0% { opacity: 0.45; }
100% { opacity: 1.00; }
}
</style>
</head>
<body>
<header class="hud">
<div class="group">
<span><span class="led"></span>TRANSMISSION</span>
<span>OPHI-118 // LIVE</span>
</div>
<div class="group">
<span class="dim">SIGNAL</span>
<span id="signal">▮▮▮▯▯</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>
<div class="camera-frame" id="cam">
<span class="tick tl"></span>
<span class="tick tr"></span>
<span class="tick bl"></span>
<span class="tick br"></span>
<span class="label">CAM 01</span>
<div class="placeholder">
<svg width="64" height="48" viewBox="0 0 64 48" xmlns="http://www.w3.org/2000/svg" fill="none">
<rect x="2" y="8" width="44" height="32" rx="2" stroke="#4fd2ff" stroke-width="2"/>
<path d="M46 18 L60 10 L60 38 L46 30 Z" stroke="#4fd2ff" stroke-width="2" stroke-linejoin="round"/>
<circle cx="14" cy="14" r="2" fill="#e63a2e"/>
</svg>
<span class="ph-text">— WAITING FOR CAMERA —</span>
<span class="ph-coords">auto-syncs from OBS scene transform</span>
</div>
</div>
<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="game-name" id="game-name"></span>
<span class="sep" id="game-mode-sep">·</span>
<span class="game-mode" id="game-mode"></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" id="mic-icon" title="microphone">🎙</span>
<span id="mic-state"></span>
<span class="sep">·</span>
<div class="np-block" id="np">
<span class="arrow"></span>
<span class="title" id="np-title"></span>
<span class="time" id="np-time">[--:-- / --:--]</span>
</div>
</div>
</footer>
<!-- Manifest from Project Loading. Same loading.js the Project Loading scene
uses, so the Game scene picks up the game / subtitle / camera / mic the
streamer already entered. -->
<script src="../loading/loading.js"></script>
<script src="../vendor/obs-config.js"></script>
<script src="../vendor/obs-ws-mini.js"></script>
<script>
'use strict';
const pad = n => String(n).padStart(2, '0');
// ── UTC clock ──
const clockEl = document.getElementById('clock');
function tickClock() {
const d = new Date();
clockEl.textContent =
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
tickClock(); setInterval(tickClock, 1000);
// ── Signal bar fluctuation ──
const sigEl = document.getElementById('signal');
setInterval(() => {
const r = Math.random();
const n = r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5;
sigEl.textContent = '▮'.repeat(n) + '▯'.repeat(5 - n);
}, 1500);
// ── Manifest ──
const m = window.__LOADING || {};
const gameName = (m.game || '—').toUpperCase();
const subtitle = m.subtitle ? m.subtitle.toUpperCase() : null;
const cameraOn = m.camera ?? true;
const micOn = m.microphone ?? true;
document.getElementById('game-name').textContent = gameName;
if (subtitle) {
document.getElementById('game-mode').textContent = subtitle;
} else {
document.getElementById('game-mode-sep').style.display = 'none';
document.getElementById('game-mode').style.display = 'none';
}
document.getElementById('mic-state').textContent = micOn ? 'ON' : 'MUTED';
document.getElementById('mic-state').style.color = micOn ? 'var(--term-fg)' : 'var(--accent)';
if (!cameraOn) document.getElementById('cam').classList.add('off');
// ── Stream elapsed time (since page load / scene activation) ──
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);
// ── OBS WS: music sync + live camera visibility ──
// URL params let you rename the camera source / scene without editing JS:
// ?camera=Webcam&scene=Game
const params = new URLSearchParams(location.search);
const CAM_SOURCE = params.get('camera') || 'Camera';
const CAM_SCENE = params.get('scene') || 'Game';
function fmtTime(s) {
if (!isFinite(s) || s < 0) return '--:--';
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
}
const npBlock = document.getElementById('np');
const npTitle = document.getElementById('np-title');
const npTime = document.getElementById('np-time');
const camEl = document.getElementById('cam');
const rootStyle = document.documentElement.style;
function setCameraVisible(visible) { camEl.classList.toggle('off', !visible); }
// Map an OBS sceneItemTransform into the four CSS vars driving the frame.
// Assumes alignment 5 (top-left) so positionX/Y is the top-left corner —
// OBS's default for newly added items, and what the Camera source uses.
function setCameraTransform(t) {
if (!t) return;
const w = (t.sourceWidth ?? 0) * (t.scaleX ?? 1);
const h = (t.sourceHeight ?? 0) * (t.scaleY ?? 1);
if (!(w > 0 && h > 0)) return;
rootStyle.setProperty('--cam-x', `${t.positionX}px`);
rootStyle.setProperty('--cam-y', `${t.positionY}px`);
rootStyle.setProperty('--cam-w', `${w}px`);
rootStyle.setProperty('--cam-h', `${h}px`);
}
async function syncCamera(obs) {
let camItemId = null;
try {
const r = await obs.call('GetSceneItemId',
{ sceneName: CAM_SCENE, sourceName: CAM_SOURCE });
camItemId = r.sceneItemId;
const e = await obs.call('GetSceneItemEnabled',
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
setCameraVisible(e.sceneItemEnabled);
const t = await obs.call('GetSceneItemTransform',
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
setCameraTransform(t.sceneItemTransform);
} catch (err) {
// Source not found — keep manifest value, don't block other features.
console.warn(`[game] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
return;
}
obs.addEventListener('event', (ev) => {
const d = ev.detail || {};
if (d.eventData?.sceneName !== CAM_SCENE
|| d.eventData?.sceneItemId !== camItemId) return;
if (d.eventType === 'SceneItemEnableStateChanged') {
setCameraVisible(d.eventData.sceneItemEnabled);
} else if (d.eventType === 'SceneItemTransformChanged') {
setCameraTransform(d.eventData.sceneItemTransform);
}
});
}
async function connectOBS() {
if (!window.__OBSWS || !window.OBSWSMini) return;
try {
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
obs.addEventListener('close', () => {
npBlock.classList.remove('on');
setTimeout(connectOBS, 2500);
});
// Music sync
obs.onCustom('mpd:state', (s) => {
if (!s.title) return;
npBlock.classList.add('on');
npTitle.textContent = s.title;
npTime.textContent = `[${fmtTime(s.currentTime)} / ${fmtTime(s.duration)}]`;
});
// Live camera visibility
await syncCamera(obs);
} catch (e) {
// OBS WS optional — silently retry; meanwhile manifest values stand.
setTimeout(connectOBS, 2500);
}
}
connectOBS();
</script>
</body>
</html>