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.
432 lines
15 KiB
HTML
432 lines
15 KiB
HTML
<!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>
|