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:
52
archived/README.md
Normal file
52
archived/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# archived/
|
||||
|
||||
Pre-`webapp/` artefacts retained for reference and rollback.
|
||||
|
||||
## scenes/
|
||||
The seven hand-authored HTML scene overlays that lived at the repo root
|
||||
before the Laravel migration:
|
||||
|
||||
- `landing/` — "PLEASE STAND BY" with telemetry rotator
|
||||
- `loading/` — project-load terminal + countdown + manifest
|
||||
- `game/` — game HUD with camera-frame OBS-WS sync
|
||||
- `desktop/` — desktop HUD (camera + screen frames)
|
||||
- `goodbye/` — sign-off terminal + "OFFLINE FOR" counter
|
||||
- `music/` — legacy browser-side music daemon (HTML5 `<audio>`)
|
||||
- `music-box/` — full HUD, widget, cover, and nc variant
|
||||
|
||||
Each `index.html` is a self-contained file with inline CSS+JS. The
|
||||
corresponding Blade view lives in
|
||||
`webapp/resources/views/overlays/<scene>.blade.php` (and `music/box.blade.php`,
|
||||
`music/widget.blade.php`, `music/cover.blade.php`, `music/nc.blade.php`,
|
||||
`music/daemon.blade.php` for the music sub-tree).
|
||||
|
||||
## scripts/
|
||||
Bash helpers that wrote `window.__X` global JS files. Replaced by Artisan:
|
||||
|
||||
| Old | New |
|
||||
|---------------------------|------------------------------------|
|
||||
| `scripts/setup.sh` | `php artisan rig:setup` |
|
||||
| `scripts/loading.sh` | `php artisan rig:loading` |
|
||||
| `scripts/playlist.sh` | `php artisan rig:playlist` |
|
||||
| `scripts/telemetry.sh` | `php artisan rig:telemetry` |
|
||||
| `scripts/playlist.sh skip`| `php artisan rig:cmd skip` (or `curl -X POST http://127.0.0.1:1118/cmd/skip`) |
|
||||
|
||||
## vendor/
|
||||
- `obs-ws-mini.js` — moved to `webapp/public/js/obs-ws-mini.js`.
|
||||
- `obs-config.js` — moved to `webapp/public/js/obs-config.js` (still
|
||||
generated by `rig:setup`).
|
||||
|
||||
## Rollback
|
||||
1. Close OBS (`flatpak kill com.obsproject.Studio`).
|
||||
2. Restore the scene JSON:
|
||||
```
|
||||
cp basic/scenes/Default_Stream_HUD.json.pre-webapp \
|
||||
basic/scenes/Default_Stream_HUD.json
|
||||
```
|
||||
3. Move directories back from `archived/scenes/` to the repo root,
|
||||
`archived/vendor/` back to `vendor/`, and `archived/scripts/*.sh`
|
||||
back to `scripts/`.
|
||||
4. Re-run `bash scripts/setup.sh` to regenerate `vendor/obs-config.js`
|
||||
at the old path.
|
||||
5. Stop the webapp: `sudo supervisorctl stop obs-webapp`.
|
||||
6. Restart OBS.
|
||||
406
archived/scenes/desktop/index.html
Normal file
406
archived/scenes/desktop/index.html
Normal file
@@ -0,0 +1,406 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / DESKTOP</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 the Desktop scene's Camera
|
||||
item via OBS WS. These defaults are first-paint fallback. */
|
||||
--cam-x: 1937px;
|
||||
--cam-y: 98px;
|
||||
--cam-w: 549px;
|
||||
--cam-h: 309px;
|
||||
--cam-pad: 10px;
|
||||
/* Screen-capture frame transform — auto-synced from the Screen Capture
|
||||
item the same way. Defaults match the current scene's bounds. */
|
||||
--scr-x: 10px;
|
||||
--scr-y: 14px;
|
||||
--scr-w: 1976px;
|
||||
--scr-h: 1209px;
|
||||
--scr-pad: 0px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes rec-blink {
|
||||
0%, 55% { opacity: 1; }
|
||||
65%, 100% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* ───────── Camera frame (shared logic with game/index.html) ───────── */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ───────── Screen-capture frame ─────────
|
||||
Same pattern as the camera frame — corner ticks + label + tracked size —
|
||||
but transparent (the desktop renders behind, must show through) and a
|
||||
thinner perimeter line, since at full-canvas scale a bright cyan rectangle
|
||||
all around the desktop reads as too loud. Bright ticks carry the "this is
|
||||
a framed feed" signal; the line itself is just a hint. */
|
||||
.screen-frame {
|
||||
position: absolute;
|
||||
top: calc(var(--scr-y) - var(--scr-pad));
|
||||
left: calc(var(--scr-x) - var(--scr-pad));
|
||||
width: calc(var(--scr-w) + var(--scr-pad) * 2);
|
||||
height: calc(var(--scr-h) + var(--scr-pad) * 2);
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(79, 210, 255, 0.30);
|
||||
box-shadow: 0 0 18px rgba(79, 210, 255, 0.08);
|
||||
}
|
||||
|
||||
.camera-frame .tick,
|
||||
.screen-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, .screen-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
||||
.camera-frame .tick.tr, .screen-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
||||
.camera-frame .tick.bl, .screen-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
||||
.camera-frame .tick.br, .screen-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
||||
.camera-frame .label,
|
||||
.screen-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,
|
||||
.screen-frame.off { display: none; }
|
||||
|
||||
/* ───────── Bottom status bar ───────── */
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0; right: 0;
|
||||
padding: 22px var(--pad-x) 26px;
|
||||
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); }
|
||||
.status-line .spacer { flex: 1 1 auto; }
|
||||
|
||||
.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;
|
||||
}
|
||||
/* OPHI-118 // DESKTOP — the rig identifier moved here from the old top HUD. */
|
||||
.scene-name {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.10em;
|
||||
}
|
||||
/* HUD tag — TRANSMISSION/SIGNAL/UTC labels formerly in the top HUD,
|
||||
restyled to sit cleanly in the bottom bar (cyan, mono, smaller). */
|
||||
.hud-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--hud);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.08em;
|
||||
text-shadow: 0 0 6px rgba(7, 8, 13, 0.9);
|
||||
}
|
||||
.hud-tag .dim { color: var(--hud-dim); }
|
||||
#signal { display: inline-block; transform: translateY(-0.10em); }
|
||||
.meta-line {
|
||||
color: var(--ink-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
.meta-line .icon { color: var(--hud); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="screen-frame" id="scr">
|
||||
<span class="tick tl"></span>
|
||||
<span class="tick tr"></span>
|
||||
<span class="tick bl"></span>
|
||||
<span class="tick br"></span>
|
||||
<span class="label">SCR 01</span>
|
||||
</div>
|
||||
|
||||
<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="scene-name">OPHI-118 // DESKTOP</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="hud-tag"><span class="dim">SIGNAL</span><span id="signal">▮▮▮▯▯</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>
|
||||
|
||||
<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 (steady, like Game) ──
|
||||
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);
|
||||
|
||||
// ── 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: live frame sync for camera + screen capture ──
|
||||
// URL params:
|
||||
// ?camera=Webcam override the source name to track for camera
|
||||
// ?screen=Display override the source name to track for screen
|
||||
// ?scene=Desktop LOCK the HUD to a specific scene (optional)
|
||||
// When ?scene= is omitted (the default) the HUD follows whichever scene is
|
||||
// currently program — so the same browser source can be referenced in both
|
||||
// Desktop and Desktop (No Cam): switching scenes rebinds to the items in
|
||||
// the now-active scene, and the camera/screen frames toggle/move with it.
|
||||
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;
|
||||
|
||||
// Compute rendered (w, h) on canvas from an OBS sceneItemTransform.
|
||||
// Camera item has no bounds → scale × source. Screen capture uses
|
||||
// bounds_type=stretch (1) → boundsWidth/boundsHeight are authoritative.
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// Frame binding for one tracked source. Holds mutable {sceneName, itemId}
|
||||
// so the event filter reads through to the *current* binding — that lets
|
||||
// rebind() swap scenes without leaking listeners or stale itemIds.
|
||||
// Assumes alignment 5 (top-left) — OBS's default for newly added items.
|
||||
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 {
|
||||
// Source isn't in this scene (or scene doesn't exist) — hide
|
||||
// the frame outright. This is what makes "Desktop (No Cam)"
|
||||
// work cleanly even if Camera were fully removed (rather than
|
||||
// just disabled).
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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', () => setTimeout(connectOBS, 2500));
|
||||
|
||||
const camFrame = makeFrame(obs, CAM_SOURCE, camEl, 'cam');
|
||||
const scrFrame = makeFrame(obs, SCR_SOURCE, scrEl, 'scr');
|
||||
|
||||
// Resolve the scene to track. With ?scene= explicit, lock to it;
|
||||
// otherwise follow whatever's currently program. Modern obs-websocket
|
||||
// returns currentProgramSceneName; older builds returned sceneName —
|
||||
// accept both.
|
||||
let scene = SCENE_LOCK;
|
||||
if (!scene) {
|
||||
try {
|
||||
const r = await obs.call('GetCurrentProgramScene');
|
||||
scene = r.currentProgramSceneName || r.sceneName || null;
|
||||
} catch { /* leave null; rebind on first scene change */ }
|
||||
}
|
||||
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);
|
||||
});
|
||||
} catch (e) {
|
||||
// OBS WS optional — silently retry; defaults stand meanwhile.
|
||||
setTimeout(connectOBS, 2500);
|
||||
}
|
||||
}
|
||||
connectOBS();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
431
archived/scenes/game/index.html
Normal file
431
archived/scenes/game/index.html
Normal 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>
|
||||
611
archived/scenes/goodbye/index.html
Normal file
611
archived/scenes/goodbye/index.html
Normal file
@@ -0,0 +1,611 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / SIGN-OFF</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
--offair: #ff6f5a;
|
||||
|
||||
--term-bg: #04120a;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.55);
|
||||
--term-edge: rgba(80, 220, 100, 0.45);
|
||||
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
padding: 40px 72px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* ───────── HUD top strip ───────── */
|
||||
.hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hud .group { display: flex; gap: 28px; 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: 12px; }
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--offair);
|
||||
box-shadow: 0 0 10px var(--offair);
|
||||
/* 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 2.4s 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%, 65% { opacity: 1; }
|
||||
75%, 100% { opacity: 0.15; }
|
||||
}
|
||||
|
||||
/* ───────── Terminal stage ───────── */
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 60vw;
|
||||
max-width: 1380px;
|
||||
height: 58vh;
|
||||
max-height: 740px;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
border: 2px solid var(--term-edge);
|
||||
border-radius: 6px;
|
||||
padding: 28px 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
line-height: 1.55;
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
box-shadow:
|
||||
0 0 60px rgba(80, 220, 100, 0.10),
|
||||
inset 0 0 90px rgba(0, 30, 0, 0.65);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.terminal::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.28) 3px,
|
||||
rgba(0,0,0,0.28) 4px
|
||||
);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.term-line { white-space: pre-wrap; word-break: break-word; }
|
||||
.term-prompt { color: var(--term-fg-bright); }
|
||||
.term-out { color: var(--term-fg); }
|
||||
.term-dim { color: var(--term-fg-dim); }
|
||||
.term-warn { color: var(--warn); }
|
||||
.term-cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1em;
|
||||
background: var(--term-fg);
|
||||
margin-left: 6px;
|
||||
vertical-align: -0.15em;
|
||||
box-shadow: 0 0 8px var(--term-glow);
|
||||
animation: term-blink 1.05s steps(2) infinite;
|
||||
}
|
||||
@keyframes term-blink { 50% { opacity: 0; } }
|
||||
|
||||
.term-now-playing { margin-top: 10px; }
|
||||
.term-now-playing .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
margin-right: 10px;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.term-now-playing .np-title { color: var(--term-fg-bright); }
|
||||
.term-now-playing .np-time { color: var(--term-fg-dim); margin-left: 12px; }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ───────── Big sign-off banner ───────── */
|
||||
/* text-indent compensates for trailing letter-spacing on the last char,
|
||||
which would otherwise be included in the centered inline box and shift
|
||||
visible text left by half the letter-spacing value. */
|
||||
.banner { text-align: center; }
|
||||
.banner .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.banner .title {
|
||||
font-family: var(--mono);
|
||||
font-size: 116px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-shadow:
|
||||
0 0 22px rgba(255, 111, 90, 0.18),
|
||||
0 0 48px rgba(230, 58, 46, 0.10);
|
||||
animation: sign-pulse 3.6s ease-in-out infinite;
|
||||
}
|
||||
.banner .sub {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes sign-pulse {
|
||||
0%, 100% { opacity: 0.88; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ───────── Footer ───────── */
|
||||
.foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 19px;
|
||||
color: var(--hud-dim);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.foot > *:nth-child(1) { justify-self: start; }
|
||||
.foot > *:nth-child(2) { justify-self: center; }
|
||||
.foot > *:nth-child(3) { justify-self: end; }
|
||||
.foot .offair { color: var(--offair); }
|
||||
|
||||
/* ───────── Body overlays ───────── */
|
||||
#static {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.045;
|
||||
mix-blend-mode: screen;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.20) 3px,
|
||||
rgba(0,0,0,0.20) 4px
|
||||
);
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,0.60) 100%
|
||||
);
|
||||
}
|
||||
.flicker {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0);
|
||||
animation: flicker 11s ease-in-out infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { background: rgba(0,0,0,0); }
|
||||
18% { background: rgba(0,0,0,0); }
|
||||
18.3% { background: rgba(0,0,0,0.06); }
|
||||
18.5% { background: rgba(0,0,0,0); }
|
||||
47% { background: rgba(0,0,0,0); }
|
||||
47.3% { background: rgba(0,0,0,0.08); }
|
||||
47.5% { background: rgba(0,0,0,0); }
|
||||
78% { background: rgba(0,0,0,0); }
|
||||
78.4% { background: rgba(0,0,0,0.05); }
|
||||
78.7% { background: rgba(0,0,0,0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hud">
|
||||
<div class="group">
|
||||
<span><span class="led"></span>OFF AIR</span>
|
||||
<span>OPHI-118 // SIGN-OFF</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>
|
||||
|
||||
<main class="stage">
|
||||
<div class="terminal">
|
||||
<div id="termOutput"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="banner" id="banner">
|
||||
<div class="label">— TRANSMISSION ENDED —</div>
|
||||
<div class="title" id="bannerTitle">GOOD BYE</div>
|
||||
<div class="sub" id="bannerSub">— THANKS FOR TUNING IN —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— PROJECT MANIFEST UNLOADED —</span>
|
||||
<span class="offair">■ OFF AIR</span>
|
||||
</footer>
|
||||
|
||||
<canvas id="static"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="vignette"></div>
|
||||
<div class="flicker"></div>
|
||||
|
||||
<!-- Playlist manifest written by scripts/playlist.sh — used here just for the
|
||||
track count in the closing terminal output. Actual playback continues in
|
||||
the shared Music Daemon source. -->
|
||||
<script src="../loading/playlist.js"></script>
|
||||
<!-- OBS WebSocket — receives mpd:state broadcasts from the daemon so the
|
||||
now-playing line keeps updating during sign-off. -->
|
||||
<script src="../vendor/obs-config.js"></script>
|
||||
<script src="../vendor/obs-ws-mini.js"></script>
|
||||
|
||||
<script>
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ── UTC clock ──
|
||||
const clockEl = document.getElementById('clock');
|
||||
const tickClock = () => {
|
||||
const d = new Date();
|
||||
clockEl.textContent = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
};
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
|
||||
// ── Signal bar — degrading: more 1s and 2s than during loading ──
|
||||
const sigEl = document.getElementById('signal');
|
||||
const drawSig = n => '▮'.repeat(n) + '▯'.repeat(5 - n);
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
sigEl.textContent = drawSig(r < 0.45 ? 1 : r < 0.85 ? 2 : r < 0.97 ? 3 : 0);
|
||||
}, 1500);
|
||||
|
||||
// ── Static (CRT noise) ──
|
||||
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
|
||||
const W = 320, H = 180;
|
||||
cv.width = W; cv.height = H;
|
||||
const img = ctx.createImageData(W, H);
|
||||
const drawNoise = () => {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (Math.random() * 255) | 0;
|
||||
d[i] = d[i+1] = d[i+2] = v;
|
||||
d[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
};
|
||||
drawNoise();
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
|
||||
// ── Sign-off sequence (terminal output) ──
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const MODE_KEYS = ['repeat', 'random', 'single'];
|
||||
const lines = [
|
||||
{ kind: 'cmd', text: 'unloadproject --flush --persist-state' },
|
||||
{ kind: 'out', text: 'closing transmission envelope...' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: 'session :: archived' },
|
||||
{ kind: 'out', text: 'camera :: released' },
|
||||
{ kind: 'out', text: 'microphone :: released' },
|
||||
{ kind: 'out', text: 'capture :: released' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'dim', text: 'flushing buffers...' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: 'TRANSMISSION ENDED.' },
|
||||
{ kind: 'warn', text: '— see you on the next channel —' },
|
||||
];
|
||||
|
||||
const term = document.getElementById('termOutput');
|
||||
|
||||
const MAX_TERM_LINES = 200;
|
||||
function pruneTerminal() {
|
||||
// Cap total node count (memory).
|
||||
while (term.children.length > MAX_TERM_LINES) {
|
||||
const first = term.firstElementChild;
|
||||
if (!first || first === npLine) break;
|
||||
term.removeChild(first);
|
||||
}
|
||||
// Sum rendered heights of children and drop top until they fit. Container
|
||||
// is flex/justify-end with overflow:hidden, so neither scrollHeight nor
|
||||
// child positions reliably indicate top overflow — but each child's own
|
||||
// rendered height does, regardless of clip.
|
||||
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 === npLine) break;
|
||||
term.removeChild(first);
|
||||
}
|
||||
}
|
||||
|
||||
function append(prompt, text, cls) {
|
||||
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.appendChild(div);
|
||||
pruneTerminal();
|
||||
return t;
|
||||
}
|
||||
|
||||
async function typeCommand(text) {
|
||||
const t = append(PROMPT, '', 'term-out');
|
||||
for (const c of text) {
|
||||
t.textContent += c;
|
||||
await sleep(38);
|
||||
}
|
||||
await sleep(280);
|
||||
}
|
||||
|
||||
function normalizePlayback(playback) {
|
||||
const src = playback && typeof playback === 'object' ? playback : {};
|
||||
const normalized = {};
|
||||
for (const key of MODE_KEYS) normalized[key] = !!src[key];
|
||||
normalized.consume = !!src.consume;
|
||||
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatMusicCommand(playback) {
|
||||
const p = normalizePlayback(playback);
|
||||
const parts = ['music', '--keep-alive'];
|
||||
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
|
||||
MODE_KEYS.forEach(key => {
|
||||
if (p[key]) parts.push(`--${key}`);
|
||||
});
|
||||
if (p.consume) parts.push('--consume');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// ── Music: passive listener; shared Music Daemon plays the audio ──
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = playlistData.tracks || [];
|
||||
let bootStarted = false;
|
||||
let bootFinished = false;
|
||||
let lastPlaybackModes = null;
|
||||
let pendingState = null;
|
||||
let modeLogChain = Promise.resolve();
|
||||
|
||||
let npLine = null;
|
||||
function ensureNowPlayingLine() {
|
||||
if (npLine) return npLine;
|
||||
npLine = document.createElement('div');
|
||||
npLine.className = 'term-line term-now-playing';
|
||||
npLine.innerHTML =
|
||||
'<span class="np-arrow">▶</span>' +
|
||||
'<span class="np-title">connecting to daemon…</span>' +
|
||||
'<span class="np-time">[--:-- / --:--]</span>';
|
||||
term.appendChild(npLine);
|
||||
return npLine;
|
||||
}
|
||||
function logBeforeNp(text, cls = 'term-out') {
|
||||
if (!npLine) { append('> ', text, cls); return; }
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const p = document.createElement('span'); p.className = 'term-prompt'; p.textContent = '> ';
|
||||
const t = document.createElement('span'); t.className = cls; t.textContent = text;
|
||||
div.appendChild(p); div.appendChild(t);
|
||||
term.insertBefore(div, npLine);
|
||||
pruneTerminal();
|
||||
}
|
||||
function fmtClock(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
|
||||
async function logModeChange(mode, enabled) {
|
||||
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
|
||||
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
|
||||
}
|
||||
|
||||
function trackModeChanges(playback) {
|
||||
const current = normalizePlayback(playback);
|
||||
if (!lastPlaybackModes) {
|
||||
lastPlaybackModes = current;
|
||||
return;
|
||||
}
|
||||
MODE_KEYS.forEach(key => {
|
||||
if (current[key] !== lastPlaybackModes[key]) {
|
||||
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
|
||||
}
|
||||
});
|
||||
lastPlaybackModes = current;
|
||||
}
|
||||
|
||||
function renderMusicState(s) {
|
||||
if (!npLine) ensureNowPlayingLine();
|
||||
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile) {
|
||||
logBeforeNp(`[audio] next: ${s.title || '?'}`);
|
||||
}
|
||||
if (s.file) lastTrackFile = s.file;
|
||||
npLine.querySelector('.np-title').textContent = s.title || '—';
|
||||
npLine.querySelector('.np-time').textContent =
|
||||
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
||||
}
|
||||
|
||||
async function bootFromState(s) {
|
||||
if (bootStarted) return;
|
||||
bootStarted = true;
|
||||
lastPlaybackModes = normalizePlayback(s.playback);
|
||||
|
||||
await typeCommand(formatMusicCommand(s.playback));
|
||||
if (allTracks.length === 0) {
|
||||
append('> ', '[audio] no tracks queued', 'term-dim');
|
||||
bootFinished = true;
|
||||
return;
|
||||
}
|
||||
|
||||
append('> ', `[audio] queue retained: ${allTracks.length} tracks`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] standby for transmission ✓', 'term-out');
|
||||
bootFinished = true;
|
||||
if (pendingState) renderMusicState(pendingState);
|
||||
}
|
||||
|
||||
// Dedupe by file URI — see music-box/index.html for the rationale.
|
||||
let lastTrackFile = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforeNp('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim');
|
||||
setTimeout(connectMusic, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', (s) => {
|
||||
pendingState = s;
|
||||
if (!bootStarted) {
|
||||
bootFromState(s);
|
||||
return;
|
||||
}
|
||||
if (!bootFinished) return;
|
||||
renderMusicState(s);
|
||||
trackModeChanges(s.playback);
|
||||
});
|
||||
} catch (e) {
|
||||
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
|
||||
setTimeout(connectMusic, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
async function startMusic() {
|
||||
await sleep(400);
|
||||
append('', '[audio] waiting for mpd state', 'term-dim');
|
||||
connectMusic();
|
||||
}
|
||||
|
||||
// ── Run sequence: type sign-off → music status ──
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
for (const ln of lines) {
|
||||
if (ln.kind === 'cmd') {
|
||||
await typeCommand(ln.text);
|
||||
} else if (ln.kind === 'gap') {
|
||||
append('', ' ', '');
|
||||
await sleep(80);
|
||||
} else {
|
||||
const cls =
|
||||
ln.kind === 'dim' ? 'term-dim' :
|
||||
ln.kind === 'warn' ? 'term-warn' :
|
||||
'term-out';
|
||||
append('> ', ln.text, cls);
|
||||
await sleep(170);
|
||||
}
|
||||
}
|
||||
await startMusic();
|
||||
})();
|
||||
|
||||
// ── Live "OFFLINE FOR" counter under the banner sub-line ──
|
||||
// Scene-load = sign-off start. Counts up indefinitely; if you switch back to
|
||||
// a live scene and return, it resets (browser source reload).
|
||||
const startMs = Date.now();
|
||||
const subEl = document.getElementById('bannerSub');
|
||||
const tickOffline = () => {
|
||||
const sec = Math.floor((Date.now() - startMs) / 1000);
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
const t = h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
||||
subEl.textContent = `— OFFLINE FOR ${t} —`;
|
||||
};
|
||||
// Wait a few seconds before the counter takes over the "thanks" sub-line,
|
||||
// so the message has time to register.
|
||||
setTimeout(() => { tickOffline(); setInterval(tickOffline, 500); }, 6000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
486
archived/scenes/landing/index.html
Normal file
486
archived/scenes/landing/index.html
Normal file
@@ -0,0 +1,486 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / STAND BY</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
padding: 40px 72px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ───────── HUD top strip ───────── */
|
||||
.hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hud .group { display: flex; gap: 28px; 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: 12px; }
|
||||
|
||||
/* "OPHI-118 // <RIG>" station identifier. The rig portion gets a subtle
|
||||
monochrome glitch — character scramble that resolves back to the real
|
||||
name every ~9s. CSS-only flicker; the scramble itself is in JS. */
|
||||
.station {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
white-space: pre; /* preserve the " // " spacing as-is */
|
||||
}
|
||||
.station .sep { color: var(--hud-dim); margin: 0 0.45em; letter-spacing: 0.32em; }
|
||||
.station .rig {
|
||||
display: inline-block;
|
||||
color: var(--hud);
|
||||
text-shadow: 0 0 10px rgba(79, 210, 255, 0.55);
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Subtle width-stable bracket dimming. The brackets sit just outside the
|
||||
name so the scramble glyphs (which can be wider than 1ch in mono) don't
|
||||
visually push the layout around. */
|
||||
}
|
||||
.station .rig::before,
|
||||
.station .rig::after {
|
||||
color: var(--hud-dim);
|
||||
font-weight: 700;
|
||||
text-shadow: none;
|
||||
}
|
||||
.station .rig::before { content: '['; margin-right: 0.32em; }
|
||||
.station .rig::after { content: ']'; margin-left: 0.32em; }
|
||||
.station .rig.glitching {
|
||||
color: var(--warn);
|
||||
text-shadow: 0 0 12px rgba(255, 208, 0, 0.5);
|
||||
}
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ───────── Centered mark ───────── */
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
}
|
||||
.mark {
|
||||
width: 360px; height: 360px;
|
||||
/* Static glow only — animated drop-shadow is CPU-expensive in CEF.
|
||||
The static breathing feel comes from the rec-blink LED + cd-pulse
|
||||
elsewhere; the mark just has a steady halo. */
|
||||
filter: drop-shadow(0 0 22px rgba(58, 209, 255, 0.35));
|
||||
}
|
||||
.bars {
|
||||
display: flex;
|
||||
width: 720px;
|
||||
height: 14px;
|
||||
opacity: 0.32;
|
||||
}
|
||||
.bars i { flex: 1; display: block; }
|
||||
.bars i:nth-child(1) { background: #c7c7c7; }
|
||||
.bars i:nth-child(2) { background: #c7c700; }
|
||||
.bars i:nth-child(3) { background: #00c7c7; }
|
||||
.bars i:nth-child(4) { background: #00c700; }
|
||||
.bars i:nth-child(5) { background: #c700c7; }
|
||||
.bars i:nth-child(6) { background: #c70000; }
|
||||
.bars i:nth-child(7) { background: #0000c7; }
|
||||
|
||||
/* ───────── Standby block ───────── */
|
||||
.standby {
|
||||
text-align: center;
|
||||
}
|
||||
.standby .big {
|
||||
font-size: 130px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.20em;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 18px rgba(79, 210, 255, 0.18);
|
||||
animation: standby-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
.standby .sub {
|
||||
margin-top: 30px;
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
letter-spacing: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes standby-pulse {
|
||||
0%, 100% { opacity: 0.90; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ───────── Bottom HUD ───────── */
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 19px;
|
||||
color: var(--hud-dim);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.foot .warn { color: var(--warn); }
|
||||
/* Mirrors .station .rig.glitching — bottom telemetry uses the same scramble
|
||||
reveal as the top rig identifier, so it shares the warn-yellow tint. */
|
||||
#rig.glitching {
|
||||
color: var(--warn);
|
||||
text-shadow: 0 0 10px rgba(255, 208, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ───────── Overlays ───────── */
|
||||
#static {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.045;
|
||||
mix-blend-mode: screen;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.20) 3px,
|
||||
rgba(0,0,0,0.20) 4px
|
||||
);
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,0.60) 100%
|
||||
);
|
||||
}
|
||||
.flicker {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0);
|
||||
animation: flicker 11s ease-in-out infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { background: rgba(0,0,0,0); }
|
||||
18% { background: rgba(0,0,0,0); }
|
||||
18.3% { background: rgba(0,0,0,0.06); }
|
||||
18.5% { background: rgba(0,0,0,0); }
|
||||
47% { background: rgba(0,0,0,0); }
|
||||
47.3% { background: rgba(0,0,0,0.08); }
|
||||
47.5% { background: rgba(0,0,0,0); }
|
||||
78% { background: rgba(0,0,0,0); }
|
||||
78.4% { background: rgba(0,0,0,0.05); }
|
||||
78.7% { background: rgba(0,0,0,0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hud">
|
||||
<div class="group">
|
||||
<span><span class="led"></span>TRANSMISSION</span>
|
||||
<span class="station">
|
||||
<span class="station-id">OPHI-118</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="rig" id="rigName">--</span>
|
||||
</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>
|
||||
|
||||
<main class="stage">
|
||||
<svg class="mark" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="100" cy="100" r="92" fill="none" stroke="#3ad1ff" stroke-width="1.2" opacity="0.35"/>
|
||||
<circle cx="100" cy="100" r="80" fill="none" stroke="#3ad1ff" stroke-width="2.5"/>
|
||||
<g stroke="#3ad1ff" stroke-width="2" opacity="0.55">
|
||||
<line x1="100" y1="8" x2="100" y2="22"/>
|
||||
<line x1="100" y1="178" x2="100" y2="192"/>
|
||||
<line x1="8" y1="100" x2="22" y2="100"/>
|
||||
<line x1="178" y1="100" x2="192" y2="100"/>
|
||||
</g>
|
||||
<polygon points="100,52 148,140 52,140"
|
||||
fill="none" stroke="#e63a2e" stroke-width="3.5" stroke-linejoin="miter"/>
|
||||
<circle cx="100" cy="116" r="4" fill="#ffd000"/>
|
||||
</svg>
|
||||
<div class="bars"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</main>
|
||||
|
||||
<section class="standby">
|
||||
<div class="big">PLEASE STAND BY</div>
|
||||
<div class="sub">— DO NOT ADJUST YOUR RECEIVER —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— TELEMETRY —</span>
|
||||
<span class="warn">▲ AUDIO ACTIVE</span>
|
||||
</footer>
|
||||
|
||||
<canvas id="static"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="vignette"></div>
|
||||
<div class="flicker"></div>
|
||||
|
||||
<!-- Static device specs as a global. Generated by telemetry.sh.
|
||||
Loaded via <script> instead of fetch() because OBS's CEF blocks
|
||||
file:// → file:// fetch (Chromium "unique origin" rule). -->
|
||||
<script src="telemetry.js"></script>
|
||||
|
||||
<script>
|
||||
// Live UTC clock
|
||||
const clockEl = document.getElementById('clock');
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const tickClock = () => {
|
||||
const d = new Date();
|
||||
clockEl.textContent = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
};
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
|
||||
// Signal bar fluctuation — 3 bars typical, occasional drop or spike
|
||||
const sigEl = document.getElementById('signal');
|
||||
const FILL = '▮', EMPTY = '▯';
|
||||
const drawSig = n => FILL.repeat(n) + EMPTY.repeat(5 - n);
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
const n = r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5;
|
||||
sigEl.textContent = drawSig(n);
|
||||
}, 1500);
|
||||
|
||||
// Animated CRT static (small buffer, scaled by browser)
|
||||
const cv = document.getElementById('static');
|
||||
const ctx = cv.getContext('2d');
|
||||
const W = 320, H = 180;
|
||||
cv.width = W; cv.height = H;
|
||||
const img = ctx.createImageData(W, H);
|
||||
const drawNoise = () => {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (Math.random() * 255) | 0;
|
||||
d[i] = d[i+1] = d[i+2] = v;
|
||||
d[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
};
|
||||
drawNoise();
|
||||
// 8 fps — visually almost identical to 12 fps but ~33% less paint work.
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
|
||||
// ───────── Cyberpunk decode/scramble reveal ─────────
|
||||
// Each call: replace target text with random glyphs for `scrambleMs`, then
|
||||
// resolve to `finalText` left-to-right. Same effect family as the cyberpunk
|
||||
// "decoder" trope; pairs with the CRT aesthetic rather than fighting it.
|
||||
// Used by both the top rig identifier and the bottom telemetry rotator.
|
||||
// ASCII-only glyph pool — DejaVu Mono renders these at 1ch each, so the
|
||||
// intermediate frames don't shift width.
|
||||
const GLITCH_GLYPHS = '01ABCDEFGHIJKLMNOPQRSTUVWXYZ#@*?!§%';
|
||||
const glyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0];
|
||||
// Tracks the in-flight scramble per element so a new call can cancel the
|
||||
// previous one (otherwise back-to-back triggers double up timers).
|
||||
const _scrambleHandles = new WeakMap();
|
||||
function scrambleReveal(el, finalText, opts = {}) {
|
||||
const { scrambleMs = 360, stepMs = 50, resolveStepMs = 60 } = opts;
|
||||
const prev = _scrambleHandles.get(el);
|
||||
if (prev) { clearInterval(prev.s); clearInterval(prev.r); }
|
||||
const len = finalText.length;
|
||||
if (!len) {
|
||||
el.textContent = '';
|
||||
el.classList.remove('glitching');
|
||||
_scrambleHandles.delete(el);
|
||||
return;
|
||||
}
|
||||
el.classList.add('glitching');
|
||||
const scrambleSteps = Math.max(1, Math.floor(scrambleMs / stepMs));
|
||||
const handle = { s: null, r: null };
|
||||
_scrambleHandles.set(el, handle);
|
||||
let step = 0;
|
||||
handle.s = setInterval(() => {
|
||||
let s = '';
|
||||
for (let i = 0; i < len; i++) s += glyph();
|
||||
el.textContent = s;
|
||||
if (++step < scrambleSteps) return;
|
||||
clearInterval(handle.s); handle.s = null;
|
||||
let resolved = 0;
|
||||
handle.r = setInterval(() => {
|
||||
resolved++;
|
||||
let s = finalText.slice(0, resolved);
|
||||
for (let i = resolved; i < len; i++) s += glyph();
|
||||
el.textContent = s;
|
||||
if (resolved < len) return;
|
||||
clearInterval(handle.r); handle.r = null;
|
||||
el.textContent = finalText;
|
||||
el.classList.remove('glitching');
|
||||
_scrambleHandles.delete(el);
|
||||
}, resolveStepMs);
|
||||
}, stepMs);
|
||||
}
|
||||
|
||||
// ───────── Rig name in the top HUD ─────────
|
||||
// Reads telemetry.js's window.__TEL.rig (set by telemetry.sh per-rig).
|
||||
// Settles on the real name, then idle-reglitches every ~9s.
|
||||
const rigNameEl = document.getElementById('rigName');
|
||||
const RIG_NAME = (window.__TEL && window.__TEL.rig) || 'UNKNOWN';
|
||||
const upperRig = RIG_NAME.toUpperCase();
|
||||
const scrambleRig = ({ initial = false } = {}) => {
|
||||
if (!upperRig.length) { rigNameEl.textContent = '--'; return; }
|
||||
scrambleReveal(rigNameEl, upperRig, {
|
||||
scrambleMs: initial ? 600 : 360, stepMs: 50, resolveStepMs: 60,
|
||||
});
|
||||
};
|
||||
// First reveal happens shortly after page load; then idle re-glitch.
|
||||
setTimeout(() => scrambleRig({ initial: true }), 600);
|
||||
setInterval(scrambleRig, 9000);
|
||||
|
||||
// ───────── Telemetry: rotating real-data subtitle ─────────
|
||||
// All lines derive from telemetry.js (window.__TEL), generated by telemetry.sh.
|
||||
const rigEl = document.getElementById('rig');
|
||||
const tel = window.__TEL || null;
|
||||
|
||||
// Compact CPU model: drop "(R)/(TM)" and "X-Core Processor" filler
|
||||
const shortCpu = (m) => (m || '')
|
||||
.replace(/\(R\)|\(TM\)/g, '')
|
||||
.replace(/\s+\S+-Core\s+Processor\b/i, '')
|
||||
.replace(/\s+Processor\b/i, '')
|
||||
.replace(/\s+CPU\b.*$/i, '')
|
||||
.replace(/\s+@.*$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const lines = [
|
||||
// CPU + MEM — one combined line
|
||||
() => {
|
||||
if (!tel?.cpu?.model) return null;
|
||||
let s = `CPU · ${shortCpu(tel.cpu.model)}`;
|
||||
if (tel.cpu.threads) s += ` · ${tel.cpu.threads}T`;
|
||||
if (tel.mem?.totalGB) s += ` · MEM ${tel.mem.totalGB} GB`;
|
||||
return s;
|
||||
},
|
||||
|
||||
// GPU
|
||||
() => {
|
||||
if (!tel?.gpu?.name) return null;
|
||||
let s = `GPU · ${tel.gpu.name}`;
|
||||
if (tel.gpu.vramTotalMB) s += ` · ${(tel.gpu.vramTotalMB / 1024).toFixed(0)} GB VRAM`;
|
||||
return s;
|
||||
},
|
||||
|
||||
// OBS output: resolution, fps, encoder
|
||||
() => {
|
||||
const o = tel?.obs;
|
||||
if (!o?.output?.w) return null;
|
||||
let s = `OUTPUT · ${o.output.w}×${o.output.h}`;
|
||||
if (o.fps) s += ` @ ${o.fps} FPS`;
|
||||
if (o.stream?.encoder) s += ` · ${o.stream.encoder}`;
|
||||
return s;
|
||||
},
|
||||
|
||||
// OBS stream: rate control + bitrate + h.264 profile
|
||||
() => {
|
||||
const s = tel?.obs?.stream;
|
||||
if (!s) return null;
|
||||
const parts = [];
|
||||
if (s.rateControl) parts.push(s.rateControl);
|
||||
if (s.bitrateKbps) parts.push(`${s.bitrateKbps} kbps`);
|
||||
if (s.profile) parts.push(s.profile.toUpperCase());
|
||||
return parts.length ? `STREAM · ${parts.join(' · ')}` : null;
|
||||
},
|
||||
|
||||
// Kernel
|
||||
() => tel?.host?.kernel ? `KERNEL · ${tel.host.kernel}` : null,
|
||||
];
|
||||
|
||||
let lineIdx = 0;
|
||||
// Pick the next non-null line, advancing lineIdx past dead slots.
|
||||
const nextLine = () => {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const v = lines[lineIdx]();
|
||||
if (v) return v;
|
||||
lineIdx = (lineIdx + 1) % lines.length;
|
||||
}
|
||||
return '— TELEMETRY OFFLINE —';
|
||||
};
|
||||
|
||||
// Initial paint goes in directly so the page loads with content visible;
|
||||
// every subsequent transition uses the scramble reveal (same effect as the
|
||||
// top rig identifier).
|
||||
rigEl.textContent = nextLine();
|
||||
// Bottom lines are much longer than the rig name (~22–55 chars vs ~5–8),
|
||||
// so adapt the per-char resolve step to keep total reveal under ~700ms.
|
||||
const swapLine = () => {
|
||||
const text = nextLine();
|
||||
const len = Math.max(text.length, 1);
|
||||
scrambleReveal(rigEl, text, {
|
||||
scrambleMs: 280,
|
||||
stepMs: 45,
|
||||
resolveStepMs: Math.max(15, Math.floor(700 / len)),
|
||||
});
|
||||
};
|
||||
setInterval(() => {
|
||||
lineIdx = (lineIdx + 1) % lines.length;
|
||||
swapLine();
|
||||
}, 6000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
42
archived/scenes/landing/telemetry.json
Normal file
42
archived/scenes/landing/telemetry.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"collectedAt": "2026-04-26T20:17:38Z",
|
||||
"rig": "Ignia",
|
||||
"cpu": {
|
||||
"model": "AMD Ryzen 5",
|
||||
"threads": 12
|
||||
},
|
||||
"mem": {
|
||||
"totalGB": 32
|
||||
},
|
||||
"host": {
|
||||
"kernel": "Arch Linux 6.18.23-1-lts"
|
||||
},
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 3060 Ti",
|
||||
"vramTotalMB": 8192
|
||||
},
|
||||
"obs": {
|
||||
"profile": "ophi118",
|
||||
"renderer": "OpenGL",
|
||||
"outputMode": "Advanced",
|
||||
"canvas": { "w": 2560, "h": 1336 },
|
||||
"output": { "w": 1920, "h": 1080 },
|
||||
"fps": 30,
|
||||
"color": {
|
||||
"format": "NV12",
|
||||
"space": "709",
|
||||
"range": "Partial"
|
||||
},
|
||||
"stream": {
|
||||
"encoder": "x264",
|
||||
"rateControl": "CBR",
|
||||
"bitrateKbps": null,
|
||||
"keyintSec": 2,
|
||||
"profile": "high"
|
||||
},
|
||||
"audio": {
|
||||
"sampleRateHz": 48000,
|
||||
"channels": "Stereo"
|
||||
}
|
||||
}
|
||||
}
|
||||
632
archived/scenes/loading/index.html
Normal file
632
archived/scenes/loading/index.html
Normal file
@@ -0,0 +1,632 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / PROJECT LOADING</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
|
||||
--term-bg: #04120a;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.55);
|
||||
--term-edge: rgba(80, 220, 100, 0.45);
|
||||
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
padding: 40px 72px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* ───────── HUD top strip ───────── */
|
||||
.hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hud .group { display: flex; gap: 28px; 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: 12px; }
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ───────── Terminal stage ───────── */
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 60vw;
|
||||
max-width: 1380px;
|
||||
height: 62vh;
|
||||
max-height: 820px;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
border: 2px solid var(--term-edge);
|
||||
border-radius: 6px;
|
||||
padding: 28px 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
line-height: 1.55;
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
box-shadow:
|
||||
0 0 60px rgba(80, 220, 100, 0.10),
|
||||
inset 0 0 90px rgba(0, 30, 0, 0.65);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* New lines anchor at the bottom; older lines overflow off the top
|
||||
(clipped by the parent's overflow:hidden) — true terminal behavior. */
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Phosphor scan lines — only inside the terminal panel */
|
||||
.terminal::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.28) 3px,
|
||||
rgba(0,0,0,0.28) 4px
|
||||
);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.term-line { white-space: pre-wrap; word-break: break-word; }
|
||||
.term-prompt { color: var(--term-fg-bright); }
|
||||
.term-out { color: var(--term-fg); }
|
||||
.term-dim { color: var(--term-fg-dim); }
|
||||
.term-cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1em;
|
||||
background: var(--term-fg);
|
||||
margin-left: 6px;
|
||||
vertical-align: -0.15em;
|
||||
box-shadow: 0 0 8px var(--term-glow);
|
||||
animation: term-blink 1.05s steps(2) infinite;
|
||||
}
|
||||
@keyframes term-blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Pinned now-playing line — sits at the bottom of term output, updates live */
|
||||
.term-now-playing { margin-top: 10px; }
|
||||
.term-now-playing .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
margin-right: 10px;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.term-now-playing .np-title { color: var(--term-fg-bright); }
|
||||
.term-now-playing .np-time { color: var(--term-fg-dim); margin-left: 12px; }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ───────── Big countdown ───────── */
|
||||
/* text-indent compensates for trailing letter-spacing on the last char,
|
||||
which would otherwise be included in the centered inline box and shift
|
||||
visible text left by half the letter-spacing value. */
|
||||
.countdown { text-align: center; }
|
||||
.countdown .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.countdown .digits {
|
||||
font-family: var(--mono);
|
||||
font-size: 132px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.10em;
|
||||
text-indent: 0.10em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
|
||||
animation: cd-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
.countdown .sub {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes cd-pulse {
|
||||
0%, 100% { opacity: 0.92; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
.countdown.ready .digits {
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 32px var(--term-glow);
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
}
|
||||
.countdown.ready .sub { color: var(--term-fg-bright); }
|
||||
.countdown.ready .label { color: var(--term-fg-dim); }
|
||||
|
||||
/* ───────── Footer ───────── */
|
||||
.foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 19px;
|
||||
color: var(--hud-dim);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.foot > *:nth-child(1) { justify-self: start; }
|
||||
.foot > *:nth-child(2) { justify-self: center; }
|
||||
.foot > *:nth-child(3) { justify-self: end; }
|
||||
.foot .warn { color: var(--warn); }
|
||||
|
||||
/* ───────── Body overlays ───────── */
|
||||
#static {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.045;
|
||||
mix-blend-mode: screen;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.20) 3px,
|
||||
rgba(0,0,0,0.20) 4px
|
||||
);
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,0.60) 100%
|
||||
);
|
||||
}
|
||||
.flicker {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0);
|
||||
animation: flicker 11s ease-in-out infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { background: rgba(0,0,0,0); }
|
||||
18% { background: rgba(0,0,0,0); }
|
||||
18.3% { background: rgba(0,0,0,0.06); }
|
||||
18.5% { background: rgba(0,0,0,0); }
|
||||
47% { background: rgba(0,0,0,0); }
|
||||
47.3% { background: rgba(0,0,0,0.08); }
|
||||
47.5% { background: rgba(0,0,0,0); }
|
||||
78% { background: rgba(0,0,0,0); }
|
||||
78.4% { background: rgba(0,0,0,0.05); }
|
||||
78.7% { background: rgba(0,0,0,0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hud">
|
||||
<div class="group">
|
||||
<span><span class="led"></span>TRANSMISSION</span>
|
||||
<span>OPHI-118 // PROJECT LOAD</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>
|
||||
|
||||
<main class="stage">
|
||||
<div class="terminal">
|
||||
<div id="termOutput"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="countdown" id="cd">
|
||||
<div class="label">— STARTING IN —</div>
|
||||
<div class="digits" id="cdDigits">--:--</div>
|
||||
<div class="sub">— TRANSMISSION INCOMING —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— PROJECT MANIFEST LOADED —</span>
|
||||
<span class="warn">▲ ARMED</span>
|
||||
</footer>
|
||||
|
||||
<canvas id="static"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="vignette"></div>
|
||||
<div class="flicker"></div>
|
||||
|
||||
<!-- Rig + hardware telemetry written by scripts/telemetry.sh — exposes window.__TEL.rig
|
||||
for the "detected rig :: …" terminal line below. Same wrapper trick. -->
|
||||
<script src="../landing/telemetry.js"></script>
|
||||
<!-- Manifest data written by scripts/loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
|
||||
<script src="loading.js"></script>
|
||||
<!-- Playlist manifest written by scripts/playlist.sh — same wrapper trick. Used here
|
||||
just for the track count in the terminal output; actual playback runs
|
||||
in the separate Music Daemon source. -->
|
||||
<script src="playlist.js"></script>
|
||||
<!-- OBS WebSocket connection — receives mpd:state broadcasts from the daemon -->
|
||||
<script src="../vendor/obs-config.js"></script>
|
||||
<script src="../vendor/obs-ws-mini.js"></script>
|
||||
|
||||
<script>
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ── UTC clock ──
|
||||
const clockEl = document.getElementById('clock');
|
||||
const 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');
|
||||
const drawSig = n => '▮'.repeat(n) + '▯'.repeat(5 - n);
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
sigEl.textContent = drawSig(r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5);
|
||||
}, 1500);
|
||||
|
||||
// ── Static (CRT noise) ──
|
||||
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
|
||||
const W = 320, H = 180;
|
||||
cv.width = W; cv.height = H;
|
||||
const img = ctx.createImageData(W, H);
|
||||
const drawNoise = () => {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (Math.random() * 255) | 0;
|
||||
d[i] = d[i+1] = d[i+2] = v;
|
||||
d[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
};
|
||||
drawNoise();
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
|
||||
// ── Manifest data (window.__LOADING) ──
|
||||
const data = window.__LOADING || {};
|
||||
const onOff = b => (b ? 'ENABLED' : 'DISABLED');
|
||||
const upper = s => (s || '').toUpperCase();
|
||||
const cdMin = data.countdownMin ?? 5;
|
||||
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const MODE_KEYS = ['repeat', 'random', 'single'];
|
||||
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
|
||||
const lines = [
|
||||
{ kind: 'cmd', text: 'loadproject --manifest' },
|
||||
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
|
||||
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
|
||||
{ kind: 'out', text: `camera :: ${onOff(data.camera ?? true)}` },
|
||||
{ kind: 'out', text: `microphone :: ${onOff(data.microphone ?? true)}` },
|
||||
{ kind: 'out', text: `countdown :: ${pad(Math.round(cdMin))}:00` },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'dim', text: 'all systems nominal' },
|
||||
{ kind: 'out', text: 'READY.' },
|
||||
];
|
||||
|
||||
// ── Build terminal output ──
|
||||
const term = document.getElementById('termOutput');
|
||||
const cursorLine = document.getElementById('termCursorLine');
|
||||
|
||||
// Cap DOM growth: visually old lines have already scrolled off the top
|
||||
// (clipped by overflow:hidden), so dropping them costs nothing.
|
||||
const MAX_TERM_LINES = 200;
|
||||
function pruneTerminal() {
|
||||
while (term.children.length > MAX_TERM_LINES) {
|
||||
const first = term.firstElementChild;
|
||||
if (!first || first === npLine) break;
|
||||
term.removeChild(first);
|
||||
}
|
||||
}
|
||||
|
||||
function append(prompt, text, cls) {
|
||||
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.appendChild(div);
|
||||
pruneTerminal();
|
||||
return t;
|
||||
}
|
||||
|
||||
// ── Helpers shared between manifest and music sequences ──
|
||||
async function typeCommand(text) {
|
||||
const t = append(PROMPT, '', 'term-out');
|
||||
for (const c of text) {
|
||||
t.textContent += c;
|
||||
await sleep(38);
|
||||
}
|
||||
await sleep(280);
|
||||
}
|
||||
|
||||
function normalizePlayback(playback) {
|
||||
const src = playback && typeof playback === 'object' ? playback : {};
|
||||
const normalized = {};
|
||||
for (const key of MODE_KEYS) normalized[key] = !!src[key];
|
||||
normalized.consume = !!src.consume;
|
||||
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatMusicCommand(playback) {
|
||||
const p = normalizePlayback(playback);
|
||||
const parts = ['music', '--boot'];
|
||||
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
|
||||
MODE_KEYS.forEach(key => {
|
||||
if (p[key]) parts.push(`--${key}`);
|
||||
});
|
||||
if (p.consume) parts.push('--consume');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// ── Music: passive listener; daemon (music/index.html) plays the audio ──
|
||||
// Track count comes from playlist.js; current track + progress arrive over
|
||||
// OBS WebSocket as 'mpd:state' broadcasts from the daemon.
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = playlistData.tracks || [];
|
||||
let bootStarted = false;
|
||||
let bootFinished = false;
|
||||
let lastPlaybackModes = null;
|
||||
let pendingState = null;
|
||||
let modeLogChain = Promise.resolve();
|
||||
|
||||
let npLine = null;
|
||||
function ensureNowPlayingLine() {
|
||||
if (npLine) return npLine;
|
||||
npLine = document.createElement('div');
|
||||
npLine.className = 'term-line term-now-playing';
|
||||
npLine.innerHTML =
|
||||
'<span class="np-arrow">▶</span>' +
|
||||
'<span class="np-title">connecting to daemon…</span>' +
|
||||
'<span class="np-time">[--:-- / --:--]</span>';
|
||||
term.appendChild(npLine);
|
||||
return npLine;
|
||||
}
|
||||
function logBeforeNp(text, cls = 'term-out') {
|
||||
if (!npLine) { append('> ', text, cls); return; }
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const p = document.createElement('span'); p.className = 'term-prompt'; p.textContent = '> ';
|
||||
const t = document.createElement('span'); t.className = cls; t.textContent = text;
|
||||
div.appendChild(p); div.appendChild(t);
|
||||
term.insertBefore(div, npLine);
|
||||
pruneTerminal();
|
||||
}
|
||||
function fmtClock(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
|
||||
async function logModeChange(mode, enabled) {
|
||||
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
|
||||
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
|
||||
}
|
||||
|
||||
function trackModeChanges(playback) {
|
||||
const current = normalizePlayback(playback);
|
||||
if (!lastPlaybackModes) {
|
||||
lastPlaybackModes = current;
|
||||
return;
|
||||
}
|
||||
MODE_KEYS.forEach(key => {
|
||||
if (current[key] !== lastPlaybackModes[key]) {
|
||||
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
|
||||
}
|
||||
});
|
||||
lastPlaybackModes = current;
|
||||
}
|
||||
|
||||
function renderMusicState(s) {
|
||||
if (!npLine) ensureNowPlayingLine();
|
||||
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile) {
|
||||
logBeforeNp(`[audio] next: ${s.title || '?'}`);
|
||||
}
|
||||
if (s.file) lastTrackFile = s.file;
|
||||
npLine.querySelector('.np-title').textContent = s.title || '—';
|
||||
npLine.querySelector('.np-time').textContent =
|
||||
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
||||
}
|
||||
|
||||
async function bootFromState(s) {
|
||||
if (bootStarted) return;
|
||||
bootStarted = true;
|
||||
lastPlaybackModes = normalizePlayback(s.playback);
|
||||
|
||||
await typeCommand(formatMusicCommand(s.playback));
|
||||
if (allTracks.length === 0) {
|
||||
append('> ', '[audio] no tracks queued — run scripts/playlist.sh', 'term-dim');
|
||||
bootFinished = true;
|
||||
return;
|
||||
}
|
||||
|
||||
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', `[audio] uplink to daemon @ ophi-118://${rigName.toLowerCase()}.audio.bus`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] chat ops armed :: !skip · !queue · !info', 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] standby for transmission ✓', 'term-out');
|
||||
bootFinished = true;
|
||||
if (pendingState) renderMusicState(pendingState);
|
||||
}
|
||||
|
||||
// Connect to OBS WebSocket and listen for daemon broadcasts.
|
||||
// Dedupe by file URI — see music-box/index.html for the rationale.
|
||||
let lastTrackFile = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforeNp('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim');
|
||||
setTimeout(connectMusic, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', (s) => {
|
||||
pendingState = s;
|
||||
if (!bootStarted) {
|
||||
bootFromState(s);
|
||||
return;
|
||||
}
|
||||
if (!bootFinished) return;
|
||||
renderMusicState(s);
|
||||
trackModeChanges(s.playback);
|
||||
});
|
||||
} catch (e) {
|
||||
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
|
||||
setTimeout(connectMusic, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
async function startMusic() {
|
||||
await sleep(500);
|
||||
append('', '[audio] waiting for mpd state', 'term-dim');
|
||||
connectMusic();
|
||||
}
|
||||
|
||||
// ── Run sequence: type manifest → READY → music ──
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
for (const ln of lines) {
|
||||
if (ln.kind === 'cmd') {
|
||||
await typeCommand(ln.text);
|
||||
} else if (ln.kind === 'gap') {
|
||||
append('', ' ', '');
|
||||
await sleep(80);
|
||||
} else {
|
||||
append('> ', ln.text, ln.kind === 'dim' ? 'term-dim' : 'term-out');
|
||||
await sleep(170);
|
||||
}
|
||||
}
|
||||
await startMusic();
|
||||
})();
|
||||
|
||||
// ── Live countdown (starts at page load) ──
|
||||
const startMs = Date.now();
|
||||
const totalMs = cdMin * 60 * 1000;
|
||||
const cdEl = document.getElementById('cd');
|
||||
const cdDigits = document.getElementById('cdDigits');
|
||||
const cdLabel = cdEl.querySelector('.label');
|
||||
const cdSub = cdEl.querySelector('.sub');
|
||||
let readyShown = false;
|
||||
|
||||
const tickCountdown = () => {
|
||||
const remaining = Math.max(0, totalMs - (Date.now() - startMs));
|
||||
if (remaining <= 0) {
|
||||
if (!readyShown) {
|
||||
readyShown = true;
|
||||
cdEl.classList.add('ready');
|
||||
cdLabel.textContent = '— STREAM ACTIVE —';
|
||||
cdDigits.textContent = 'READY';
|
||||
cdSub.textContent = '— TRANSMISSION GO —';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const m = Math.floor(remaining / 60000);
|
||||
const s = Math.floor((remaining % 60000) / 1000);
|
||||
cdDigits.textContent = `${pad(m)}:${pad(s)}`;
|
||||
};
|
||||
tickCountdown();
|
||||
setInterval(tickCountdown, 250);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
295
archived/scenes/music-box/cover.html
Normal file
295
archived/scenes/music-box/cover.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Music Box cover</title>
|
||||
<!--
|
||||
Album-art widget. Subscribes to mpd:state on the OBS WS bus and renders
|
||||
the cover bytes the bridge writes to ../bridges/cover.jpg. Cache-busting
|
||||
uses ?v=<coverHash> so a new track swaps the image atomically.
|
||||
|
||||
Sized to fit any source — uses object-fit: contain so a square cover
|
||||
letterboxes inside a non-square frame instead of stretching. Author the
|
||||
OBS browser_source's width/height to match the scene-item dimensions
|
||||
you place it at (bounds_type 0, no stretch — same convention as
|
||||
music-box/widget.html).
|
||||
|
||||
URL params:
|
||||
?bars=0 hide the top header + bottom caption strip; just the cyan
|
||||
frame around the artwork (used when the widget stands in
|
||||
for the camera in compact scenes).
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #04120a;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.45);
|
||||
--frame: rgba(79, 210, 255, 0.85);
|
||||
--frame-glow: rgba(79, 210, 255, 0.30);
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--term-fg);
|
||||
font-family: var(--mono);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
/* Stage holds the artwork. Both <img> and placeholder are absolutely
|
||||
positioned full-bleed; we cross-fade between them on hash change. */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Same scanline treatment as music-box/widget.html so the widget reads
|
||||
as part of the same family. Sits above the artwork; below corners. */
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.18) 3px,
|
||||
rgba(0,0,0,0.18) 4px
|
||||
);
|
||||
opacity: 0.32;
|
||||
mix-blend-mode: multiply;
|
||||
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); }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ?bars=0 — strip header + caption so the artwork + cyan frame is all
|
||||
that remains. The art-stage already flex-grows to fill, so dropping
|
||||
the siblings just gives it the entire body. */
|
||||
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>
|
||||
|
||||
<script src="../vendor/obs-config.js"></script>
|
||||
<script src="../vendor/obs-ws-mini.js"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// ?bars=0 → minimal mode: just frame + art, no header/caption strip.
|
||||
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');
|
||||
|
||||
let lastHash = undefined; // undefined = never seen; null = no art
|
||||
|
||||
function showArt(path, hash) {
|
||||
if (hash === lastHash) return;
|
||||
lastHash = hash;
|
||||
if (!path || !hash) {
|
||||
coverImg.classList.remove('on');
|
||||
placeholder.classList.remove('off');
|
||||
coverImg.removeAttribute('src');
|
||||
return;
|
||||
}
|
||||
// ?v=<hash> cache-busts the canonical cover.jpg path so CEF doesn't
|
||||
// serve a stale image when the bridge swaps bytes.
|
||||
coverImg.onload = () => {
|
||||
coverImg.classList.add('on');
|
||||
placeholder.classList.add('off');
|
||||
};
|
||||
coverImg.onerror = () => {
|
||||
coverImg.classList.remove('on');
|
||||
placeholder.classList.remove('off');
|
||||
};
|
||||
coverImg.src = `${path}?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;
|
||||
}
|
||||
|
||||
let obs = null;
|
||||
async function connect() {
|
||||
if (!window.__OBSWS) {
|
||||
setOffline('config missing');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
setOffline('disconnected');
|
||||
setTimeout(connect, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', applyState);
|
||||
} catch (e) {
|
||||
setOffline('daemon unreachable');
|
||||
setTimeout(connect, 2500);
|
||||
}
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
543
archived/scenes/music-box/index.html
Normal file
543
archived/scenes/music-box/index.html
Normal file
@@ -0,0 +1,543 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / MUSIC BOX</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
--onair: #5fdc62;
|
||||
|
||||
--term-bg: #04120a;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.55);
|
||||
--term-edge: rgba(80, 220, 100, 0.45);
|
||||
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
/* HUD | terminal | status-strip | banner | footer */
|
||||
grid-template-rows: auto 1fr auto auto auto;
|
||||
padding: 40px 72px;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ───────── HUD top strip ───────── */
|
||||
.hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hud .group { display: flex; gap: 28px; 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: 12px; }
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--onair);
|
||||
box-shadow: 0 0 10px var(--onair);
|
||||
margin-top: -0.08em;
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
#signal { display: inline-block; transform: translateY(-0.10em); }
|
||||
@keyframes rec-blink {
|
||||
0%, 55% { opacity: 1; }
|
||||
65%, 100% { opacity: 0.20; }
|
||||
}
|
||||
|
||||
/* ───────── Terminal stage ───────── */
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 64vw;
|
||||
max-width: 1480px;
|
||||
height: 60vh;
|
||||
max-height: 760px;
|
||||
min-height: 420px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
border: 2px solid var(--term-edge);
|
||||
border-radius: 6px;
|
||||
padding: 28px 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 24px;
|
||||
line-height: 1.55;
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
box-shadow:
|
||||
0 0 60px rgba(80, 220, 100, 0.10),
|
||||
inset 0 0 90px rgba(0, 30, 0, 0.65);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.terminal::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.28) 3px,
|
||||
rgba(0,0,0,0.28) 4px
|
||||
);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.term-line { word-break: break-word; }
|
||||
.term-prompt { color: var(--term-fg-bright); }
|
||||
.term-out { color: var(--term-fg); }
|
||||
.term-dim { color: var(--term-fg-dim); }
|
||||
.term-warn { color: var(--warn); }
|
||||
|
||||
.pin-spacer { height: 14px; }
|
||||
|
||||
.np-line .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
margin-right: 12px;
|
||||
display: inline-block;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.np-line .np-title { color: var(--term-fg-bright); }
|
||||
.np-line .np-time { color: var(--term-fg-dim); margin-left: 14px; }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
.next-line .next-arrow { color: var(--term-fg-dim); margin-right: 10px; }
|
||||
.next-line .next-label { color: var(--term-fg-dim); margin-right: 6px; }
|
||||
.next-line .next-title { color: var(--term-fg); }
|
||||
.next-line .next-title.empty { color: var(--term-fg-dim); }
|
||||
|
||||
/* ───────── Status strip (chat commands — always visible, not in scroll) ─────────
|
||||
Sits between the terminal and the banner. Mirrors the "always-visible
|
||||
bottom bar" pattern from terminal apps: scrolling history above, fixed
|
||||
row of available commands here. */
|
||||
.status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
font-family: var(--mono);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--hud-dim);
|
||||
padding: 10px 24px;
|
||||
border-top: 1px solid rgba(79, 210, 255, 0.22);
|
||||
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
|
||||
background: rgba(7, 8, 13, 0.55);
|
||||
}
|
||||
.status-strip .label { color: var(--hud); letter-spacing: 0.32em; }
|
||||
.status-strip .cmd-group { display: inline-flex; gap: 18px; }
|
||||
.status-strip .cmd {
|
||||
color: var(--term-fg-bright);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
}
|
||||
.status-strip .desc { color: var(--hud-dim); margin-left: 6px; font-size: 16px; letter-spacing: 0.10em; }
|
||||
|
||||
/* ───────── Big MUSIC BOX banner ───────── */
|
||||
.banner { text-align: center; }
|
||||
.banner .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.banner .title {
|
||||
font-family: var(--mono);
|
||||
font-size: 88px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-shadow:
|
||||
0 0 22px rgba(95, 220, 98, 0.18),
|
||||
0 0 48px rgba(80, 200, 100, 0.10);
|
||||
animation: sign-pulse 3.6s ease-in-out infinite;
|
||||
}
|
||||
.banner .sub {
|
||||
margin-top: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes sign-pulse {
|
||||
0%, 100% { opacity: 0.88; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ───────── Footer ───────── */
|
||||
.foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 19px;
|
||||
color: var(--hud-dim);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.foot > *:nth-child(1) { justify-self: start; }
|
||||
.foot > *:nth-child(2) { justify-self: center; }
|
||||
.foot > *:nth-child(3) { justify-self: end; }
|
||||
.foot .onair { color: var(--onair); }
|
||||
|
||||
/* ───────── Body overlays ───────── */
|
||||
#static {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.045;
|
||||
mix-blend-mode: screen;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.20) 3px,
|
||||
rgba(0,0,0,0.20) 4px
|
||||
);
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,0.60) 100%
|
||||
);
|
||||
}
|
||||
.flicker {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0);
|
||||
animation: flicker 11s ease-in-out infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { background: rgba(0,0,0,0); }
|
||||
18% { background: rgba(0,0,0,0); }
|
||||
18.3% { background: rgba(0,0,0,0.06); }
|
||||
18.5% { background: rgba(0,0,0,0); }
|
||||
47% { background: rgba(0,0,0,0); }
|
||||
47.3% { background: rgba(0,0,0,0.08); }
|
||||
47.5% { background: rgba(0,0,0,0); }
|
||||
78% { background: rgba(0,0,0,0); }
|
||||
78.4% { background: rgba(0,0,0,0.05); }
|
||||
78.7% { background: rgba(0,0,0,0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hud">
|
||||
<div class="group">
|
||||
<span><span class="led"></span>ON AIR</span>
|
||||
<span>OPHI-118 // MUSIC</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>
|
||||
|
||||
<main class="stage">
|
||||
<div class="terminal">
|
||||
<!-- Pinned block is static HTML — JS only updates text content.
|
||||
If JS errors, the structure is still visible. New log lines
|
||||
are inserted before #pinnedBlock by the JS. -->
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<canvas id="static"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="vignette"></div>
|
||||
<div class="flicker"></div>
|
||||
|
||||
<script src="../loading/playlist.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');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ── UTC clock ──
|
||||
const clockEl = document.getElementById('clock');
|
||||
const tickClock = () => {
|
||||
const d = new Date();
|
||||
clockEl.textContent =
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
};
|
||||
tickClock(); setInterval(tickClock, 1000);
|
||||
|
||||
// ── Signal ──
|
||||
const sigEl = document.getElementById('signal');
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
const n = r < 0.15 ? 3 : r < 0.65 ? 4 : 5;
|
||||
sigEl.textContent = '▮'.repeat(n) + '▯'.repeat(5 - n);
|
||||
}, 1500);
|
||||
|
||||
// ── Static (CRT noise) ──
|
||||
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
|
||||
const W = 320, H = 180;
|
||||
cv.width = W; cv.height = H;
|
||||
const img = ctx.createImageData(W, H);
|
||||
const drawNoise = () => {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (Math.random() * 255) | 0;
|
||||
d[i] = d[i+1] = d[i+2] = v;
|
||||
d[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
};
|
||||
drawNoise();
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
|
||||
// ── Footer track count from playlist manifest ──
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const trackCount = (playlistData.tracks || []).length;
|
||||
document.getElementById('rig').textContent =
|
||||
trackCount > 0 ? `LIBRARY :: ${trackCount} TRACKS` : '— EMPTY LIBRARY —';
|
||||
|
||||
// ── Terminal ──
|
||||
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))}`;
|
||||
}
|
||||
|
||||
// ── State application ──
|
||||
// Dedupe by file URI, not s.index. MPD's status.song (s.index) is the
|
||||
// queue position and shifts mid-playback when the queue is reordered
|
||||
// (consume mode removes the prior track → later indices shift down,
|
||||
// mpc shuffle reshuffles, etc.) — using it caused duplicate "now: …"
|
||||
// lines for a single playthrough.
|
||||
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 = '[--:-- / --:--]';
|
||||
}
|
||||
|
||||
let obs = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforePins('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
|
||||
setOffline('daemon disconnected');
|
||||
setTimeout(connectMusic, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', applyState);
|
||||
} catch (e) {
|
||||
logBeforePins(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
|
||||
setTimeout(connectMusic, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Boot sequence
|
||||
(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);
|
||||
connectMusic();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
883
archived/scenes/music-box/nc-music-box.html
Normal file
883
archived/scenes/music-box/nc-music-box.html
Normal file
@@ -0,0 +1,883 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / NC-MUSIC-BOX</title>
|
||||
<!--
|
||||
ncurses-styled Music Box. Forked from music-box/index.html but laid out
|
||||
as a 2-pane TUI: terminal on the left (boot + scrolling history + pinned
|
||||
next-up + now-playing), cover + metadata pane on the right.
|
||||
|
||||
Subscribes to mpd:state and uses the same coverPath/coverHash mechanism
|
||||
as music-box/cover.html — so this works alongside the existing widget,
|
||||
it doesn't replace anything.
|
||||
|
||||
Designed for the same canvas as music-box/index.html (2560×1336 stretch).
|
||||
Keep the OBS browser_source bounds_type at 1 (stretch) and width/height
|
||||
at 2560×1336 — same as the Music Box scene's existing HUD source.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
--onair: #5fdc62;
|
||||
|
||||
--term-bg: #04120a;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.55);
|
||||
--term-edge: rgba(80, 220, 100, 0.45);
|
||||
--term-edge-soft: rgba(80, 220, 100, 0.22);
|
||||
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
padding: 40px 72px;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ───────── HUD top strip ───────── */
|
||||
.hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hud .group { display: flex; gap: 28px; 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: 12px; }
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--onair);
|
||||
box-shadow: 0 0 10px var(--onair);
|
||||
margin-top: -0.08em;
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
#signal { display: inline-block; transform: translateY(-0.10em); }
|
||||
@keyframes rec-blink {
|
||||
0%, 55% { opacity: 1; }
|
||||
65%, 100% { opacity: 0.20; }
|
||||
}
|
||||
|
||||
/* ───────── Main: 2-pane TUI ─────────
|
||||
Terminal left (1fr), Now-Playing right (~30 % at canvas size). gap is
|
||||
the visual seam between panes — both panes have their own border so
|
||||
the gap reads as a column gutter, not as padding inside one pane. */
|
||||
.tui {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 36ch;
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ───────── Pane shell (shared) ─────────
|
||||
The "ncurses" feel: solid 2 px border, a tiny tab on the top edge that
|
||||
reads as a title bar. Tabs are drawn with overlapping CSS rather than
|
||||
box-drawing chars to keep alignment crisp at this size. */
|
||||
.pane {
|
||||
position: relative;
|
||||
background: var(--term-bg);
|
||||
border: 2px solid var(--term-edge);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-shadow:
|
||||
0 0 60px rgba(80, 220, 100, 0.10),
|
||||
inset 0 0 90px rgba(0, 30, 0, 0.65);
|
||||
}
|
||||
.pane::after {
|
||||
/* Pane scanline overlay — same texture as music-box/index.html. */
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.28) 3px,
|
||||
rgba(0,0,0,0.28) 4px
|
||||
);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.pane > .pane-tab {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 28px;
|
||||
padding: 2px 14px;
|
||||
background: var(--bg);
|
||||
color: var(--term-fg-bright);
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.32em;
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ───────── Terminal pane ───────── */
|
||||
.terminal {
|
||||
padding: 28px 36px;
|
||||
font-size: 24px;
|
||||
line-height: 1.55;
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
}
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-line { word-break: break-word; }
|
||||
.term-prompt { color: var(--term-fg-bright); }
|
||||
.term-out { color: var(--term-fg); }
|
||||
.term-dim { color: var(--term-fg-dim); }
|
||||
|
||||
.pin-spacer { height: 14px; }
|
||||
|
||||
.np-line .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
margin-right: 12px;
|
||||
display: inline-block;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.np-line .np-title { color: var(--term-fg-bright); }
|
||||
.np-line .np-time { color: var(--term-fg-dim); margin-left: 14px; }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
.next-line .next-arrow { color: var(--term-fg-dim); margin-right: 10px; }
|
||||
.next-line .next-label { color: var(--term-fg-dim); margin-right: 6px; }
|
||||
.next-line .next-title { color: var(--term-fg); }
|
||||
.next-line .next-title.empty { color: var(--term-fg-dim); }
|
||||
|
||||
/* ───────── Now Playing pane (right) ───────── */
|
||||
.np-pane {
|
||||
padding: 24px 22px 20px;
|
||||
font-size: 18px;
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
display: grid;
|
||||
/* art | meta | FILE | LIBRARY | <slack 1fr> | spectrum | progress
|
||||
Slack sits between the upper info block and the bottom strip so the
|
||||
spectrum analyzer reserve docks just above the progress bar — pairs
|
||||
them visually as a single "playback" zone at the foot of the pane. */
|
||||
grid-template-rows: auto auto auto auto 1fr auto auto;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* Cover stage: 1:1 square inside the pane width. */
|
||||
.np-art {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: rgba(0, 30, 0, 0.45);
|
||||
border: 1px solid var(--term-edge-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.np-art img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
.np-art img.on { opacity: 1; }
|
||||
.np-art .placeholder {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--term-fg-dim);
|
||||
text-align: center;
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
.np-art .placeholder.off { opacity: 0; }
|
||||
.np-art .placeholder svg { opacity: 0.5; }
|
||||
.np-art .placeholder .ph-text {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.42em;
|
||||
color: rgba(95, 220, 98, 0.65);
|
||||
}
|
||||
|
||||
/* Metadata key/value table — fixed-width keys read like a config block.
|
||||
Empty values render as `—` in dim color so missing tags don't visually
|
||||
collapse the row. */
|
||||
.np-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 6.5em 1fr;
|
||||
row-gap: 6px;
|
||||
column-gap: 12px;
|
||||
font-size: 17px;
|
||||
align-content: start;
|
||||
}
|
||||
.np-meta .key { color: var(--term-fg-dim); letter-spacing: 0.18em; }
|
||||
.np-meta .val {
|
||||
color: var(--term-fg-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.np-meta .val.empty { color: var(--term-fg-dim); }
|
||||
|
||||
/* LIBRARY block — sub-section inside the Now-Playing pane. Header reads
|
||||
like an inset divider rather than a full pane tab so it doesn't compete
|
||||
with the pane's own ┤ NOW PLAYING ├ at the top edge. */
|
||||
.np-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed var(--term-edge-soft);
|
||||
}
|
||||
.np-stats .stats-label {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.32em;
|
||||
color: var(--term-fg-dim);
|
||||
text-shadow: 0 0 4px var(--term-glow);
|
||||
}
|
||||
.np-stats .stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 6.5em 1fr;
|
||||
row-gap: 4px;
|
||||
column-gap: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.np-stats .stats-grid .key { color: var(--term-fg-dim); letter-spacing: 0.16em; }
|
||||
.np-stats .stats-grid .val {
|
||||
color: var(--term-fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
|
||||
|
||||
/* Lossless badge — bright green for FORMAT when the codec is FLAC/ALAC/WAV
|
||||
so the "high quality" cue reads at a glance. Lossy formats (MP3/Opus/AAC)
|
||||
stay in the regular term-fg color. */
|
||||
.np-stats .stats-grid .val.lossless {
|
||||
color: var(--term-fg-bright);
|
||||
text-shadow: 0 0 8px var(--term-glow);
|
||||
}
|
||||
|
||||
/* Spectrum slot — reserves the row that the waveform plugin source sits
|
||||
on top of. Empty + transparent: the plugin draws into this region, the
|
||||
widget just holds the layout space so progress stays in its expected
|
||||
position regardless of whether the analyzer source is enabled. */
|
||||
.np-spectrum {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
/* Progress block: thin bar + elapsed/total, all term-green. The bar fill
|
||||
transitions on width change so the second-tick advance reads smoothly
|
||||
instead of stepping. */
|
||||
.np-progress {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 6px;
|
||||
}
|
||||
.np-progress .bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: rgba(95, 220, 98, 0.14);
|
||||
border: 1px solid var(--term-edge-soft);
|
||||
}
|
||||
.np-progress .bar .fill {
|
||||
position: absolute; inset: 0 auto 0 0;
|
||||
width: 0%;
|
||||
background: var(--term-fg);
|
||||
box-shadow: 0 0 8px var(--term-glow);
|
||||
transition: width 350ms linear;
|
||||
}
|
||||
.np-progress .time {
|
||||
font-size: 15px;
|
||||
color: var(--term-fg-dim);
|
||||
letter-spacing: 0.10em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.np-progress .time .now { color: var(--term-fg-bright); }
|
||||
|
||||
/* ───────── Status strip (chat help — always visible) ───────── */
|
||||
.status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--hud-dim);
|
||||
padding: 10px 24px;
|
||||
border-top: 1px solid rgba(79, 210, 255, 0.22);
|
||||
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
|
||||
background: rgba(7, 8, 13, 0.55);
|
||||
}
|
||||
.status-strip .label { color: var(--hud); letter-spacing: 0.32em; }
|
||||
.status-strip .cmd-group { display: inline-flex; gap: 18px; }
|
||||
.status-strip .cmd {
|
||||
color: var(--term-fg-bright);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
}
|
||||
.status-strip .desc { color: var(--hud-dim); margin-left: 6px; font-size: 16px; letter-spacing: 0.10em; }
|
||||
|
||||
/* ───────── Body overlays (CRT atmosphere) ───────── */
|
||||
#static {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.045;
|
||||
mix-blend-mode: screen;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.20) 3px,
|
||||
rgba(0,0,0,0.20) 4px
|
||||
);
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,0.60) 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hud">
|
||||
<div class="group">
|
||||
<span><span class="led"></span>ON AIR</span>
|
||||
<span>OPHI-118 // MUSIC</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>
|
||||
|
||||
<main class="tui">
|
||||
<!-- ── Terminal pane ── -->
|
||||
<section class="pane terminal">
|
||||
<span class="pane-tab">┤ TERMINAL ├</span>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- ── Now-playing pane ── -->
|
||||
<aside class="pane np-pane">
|
||||
<span class="pane-tab">┤ NOW PLAYING ├</span>
|
||||
<div class="np-art">
|
||||
<img id="cover" alt="">
|
||||
<div class="placeholder" id="placeholder">
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5" y="5" width="46" height="46" rx="2" stroke="#5fdc62" stroke-width="2"/>
|
||||
<circle cx="28" cy="28" r="12" stroke="#5fdc62" stroke-width="2"/>
|
||||
<circle cx="28" cy="28" r="3" fill="#5fdc62"/>
|
||||
</svg>
|
||||
<span class="ph-text">— NO ART —</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="np-meta">
|
||||
<span class="key">ARTIST</span><span class="val empty" id="metaArtist">—</span>
|
||||
<span class="key">TITLE</span> <span class="val empty" id="metaTitle">—</span>
|
||||
<span class="key">ALBUM</span> <span class="val empty" id="metaAlbum">—</span>
|
||||
<span class="key">YEAR</span> <span class="val empty" id="metaYear">—</span>
|
||||
<span class="key">GENRE</span> <span class="val empty" id="metaGenre">—</span>
|
||||
</div>
|
||||
<div class="np-stats">
|
||||
<div class="stats-label">─ FILE ─</div>
|
||||
<div class="stats-grid">
|
||||
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat">—</span>
|
||||
<span class="key">QUALITY</span><span class="val empty" id="fileQuality">—</span>
|
||||
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="np-stats">
|
||||
<div class="stats-label">─ LIBRARY ─</div>
|
||||
<div class="stats-grid">
|
||||
<span class="key">ARTISTS</span> <span class="val empty" id="statArtists">—</span>
|
||||
<span class="key">ALBUMS</span> <span class="val empty" id="statAlbums">—</span>
|
||||
<span class="key">SONGS</span> <span class="val empty" id="statSongs">—</span>
|
||||
<span class="key">PLAYTIME</span><span class="val empty" id="statPlaytime">—</span>
|
||||
<span class="key">QUEUE</span> <span class="val empty" id="statQueue">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true"></div>
|
||||
<!-- Empty slot directly above the progress bar — the OBS waveform
|
||||
plugin source is overlaid here. We just hold the row open. -->
|
||||
<div class="np-spectrum" id="spectrumSlot"></div>
|
||||
<div class="np-progress">
|
||||
<div class="bar"><div class="fill" id="progFill"></div></div>
|
||||
<div class="time">
|
||||
<span class="now" id="progElapsed">00:00</span>
|
||||
<span id="progTotal">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</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>
|
||||
|
||||
<canvas id="static"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="vignette"></div>
|
||||
|
||||
<script src="../loading/playlist.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');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const fmtClock = s => (!isFinite(s) || s < 0)
|
||||
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
|
||||
// ── UTC clock ──
|
||||
const clockEl = document.getElementById('clock');
|
||||
const tickClock = () => {
|
||||
const d = new Date();
|
||||
clockEl.textContent =
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
};
|
||||
tickClock(); setInterval(tickClock, 1000);
|
||||
|
||||
// ── Signal ──
|
||||
const sigEl = document.getElementById('signal');
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
const n = r < 0.15 ? 3 : r < 0.65 ? 4 : 5;
|
||||
sigEl.textContent = '▮'.repeat(n) + '▯'.repeat(5 - n);
|
||||
}, 1500);
|
||||
|
||||
// ── Static (CRT noise) ──
|
||||
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
|
||||
const W = 320, H = 180;
|
||||
cv.width = W; cv.height = H;
|
||||
const img = ctx.createImageData(W, H);
|
||||
const drawNoise = () => {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (Math.random() * 255) | 0;
|
||||
d[i] = d[i+1] = d[i+2] = v;
|
||||
d[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
};
|
||||
drawNoise();
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
|
||||
// ── Track count from playlist manifest (used in boot sequence) ──
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const trackCount = (playlistData.tracks || []).length;
|
||||
|
||||
// ── Terminal ──
|
||||
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 nextRows = nextSlots.map(slot => slot.closest('.next-line'));
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const MAX_TERM_LINES = 200;
|
||||
const MODE_KEYS = ['repeat', 'random', 'single'];
|
||||
let bootStarted = false;
|
||||
let bootFinished = false;
|
||||
let lastPlaybackModes = null;
|
||||
let pendingState = null;
|
||||
|
||||
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);
|
||||
return body;
|
||||
}
|
||||
|
||||
function normalizePlayback(playback) {
|
||||
const src = playback && typeof playback === 'object' ? playback : {};
|
||||
const normalized = {};
|
||||
for (const key of MODE_KEYS) normalized[key] = !!src[key];
|
||||
normalized.consume = !!src.consume;
|
||||
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatBootCommand(playback) {
|
||||
const p = normalizePlayback(playback);
|
||||
const parts = ['music', '--boot'];
|
||||
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
|
||||
MODE_KEYS.forEach(key => {
|
||||
if (p[key]) parts.push(`--${key}`);
|
||||
});
|
||||
if (p.consume) parts.push('--consume');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function nextPreviewConfig(playback) {
|
||||
const p = normalizePlayback(playback);
|
||||
if (p.single && p.repeat) {
|
||||
return { count: 1, empty: '— repeat single armed —' };
|
||||
}
|
||||
if (p.single) {
|
||||
return { count: 0, empty: '— single mode —' };
|
||||
}
|
||||
if (p.random) {
|
||||
return { count: 1, empty: '— shuffle active —' };
|
||||
}
|
||||
return { count: 3, empty: '— end of queue —' };
|
||||
}
|
||||
|
||||
async function logModeChange(mode, enabled) {
|
||||
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
|
||||
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
|
||||
}
|
||||
|
||||
let modeLogChain = Promise.resolve();
|
||||
function trackModeChanges(playback) {
|
||||
const current = normalizePlayback(playback);
|
||||
if (!lastPlaybackModes) {
|
||||
lastPlaybackModes = current;
|
||||
return;
|
||||
}
|
||||
MODE_KEYS.forEach(key => {
|
||||
if (current[key] !== lastPlaybackModes[key]) {
|
||||
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
|
||||
}
|
||||
});
|
||||
lastPlaybackModes = current;
|
||||
}
|
||||
|
||||
function renderState(s) {
|
||||
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile && s.title) {
|
||||
logBeforePins(`[audio] now: ${s.title}`);
|
||||
}
|
||||
if (s.file) lastTrackFile = s.file;
|
||||
|
||||
// Terminal pinned block
|
||||
npTitle.textContent = s.title || '—';
|
||||
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
||||
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
|
||||
const preview = nextPreviewConfig(s.playback);
|
||||
nextSlots.forEach((slot, i) => {
|
||||
const v = i < preview.count ? titles[i] : null;
|
||||
if (v) {
|
||||
slot.textContent = v;
|
||||
slot.classList.remove('empty');
|
||||
nextRows[i].style.display = '';
|
||||
} else {
|
||||
if (i === 0) {
|
||||
slot.textContent = preview.empty;
|
||||
slot.classList.add('empty');
|
||||
nextRows[i].style.display = '';
|
||||
} else {
|
||||
slot.textContent = '';
|
||||
slot.classList.add('empty');
|
||||
nextRows[i].style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now-playing pane
|
||||
showArt(s.coverPath, s.coverHash);
|
||||
setMeta(metaArtist, s.artist);
|
||||
setMeta(metaTitle, s.trackTitle);
|
||||
setMeta(metaAlbum, s.album);
|
||||
setMeta(metaYear, s.year);
|
||||
setMeta(metaGenre, s.genre);
|
||||
|
||||
// Progress bar — clamp 0..100 so a stale duration doesn't blow it past
|
||||
// the bar end during the brief gap between track change and the next
|
||||
// status tick.
|
||||
const dur = Number(s.duration) || 0;
|
||||
const cur = Number(s.currentTime) || 0;
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(100, (cur / dur) * 100)) : 0;
|
||||
progFill.style.width = `${pct}%`;
|
||||
progElapsed.textContent = fmtClock(cur);
|
||||
progTotal.textContent = fmtClock(dur);
|
||||
|
||||
// File / audio info — codec from extension, sample rate + bit depth +
|
||||
// bitrate from MPD `status.audio` and `status.bitrate`. FLAC and friends
|
||||
// get the bright "lossless" treatment on FORMAT to advertise quality.
|
||||
const audio = s.audio || {};
|
||||
setMeta(fileFormat, audio.format);
|
||||
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
|
||||
setMeta(fileQuality, fmtQuality(audio));
|
||||
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
|
||||
|
||||
// Library stats — bridge updates `stats` ~every 30s. Queue is built
|
||||
// from the per-tick index/total fields; render 1-indexed (mpc style).
|
||||
const stats = s.stats;
|
||||
if (stats) {
|
||||
setMeta(statArtists, fmtCount(stats.artists));
|
||||
setMeta(statAlbums, fmtCount(stats.albums));
|
||||
setMeta(statSongs, fmtCount(stats.songs));
|
||||
setMeta(statPlaytime, stats.dbPlaytime);
|
||||
}
|
||||
const total = Number(s.total) || 0;
|
||||
const idx = Number.isInteger(s.index) ? s.index : -1;
|
||||
setMeta(statQueue,
|
||||
total > 0 && idx >= 0 ? `${idx + 1} / ${total}` : null);
|
||||
|
||||
pruneTerminal();
|
||||
}
|
||||
|
||||
async function bootFromState(s) {
|
||||
if (bootStarted) return;
|
||||
bootStarted = true;
|
||||
lastPlaybackModes = normalizePlayback(s.playback);
|
||||
|
||||
await typeCommand(formatBootCommand(s.playback));
|
||||
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] standby for transmission ✓', 'term-out');
|
||||
bootFinished = true;
|
||||
if (pendingState) renderState(pendingState);
|
||||
}
|
||||
|
||||
// ── Now-playing pane handles ──
|
||||
const coverImg = document.getElementById('cover');
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
const metaArtist = document.getElementById('metaArtist');
|
||||
const metaTitle = document.getElementById('metaTitle');
|
||||
const metaAlbum = document.getElementById('metaAlbum');
|
||||
const metaYear = document.getElementById('metaYear');
|
||||
const metaGenre = document.getElementById('metaGenre');
|
||||
const progFill = document.getElementById('progFill');
|
||||
const progElapsed = document.getElementById('progElapsed');
|
||||
const progTotal = document.getElementById('progTotal');
|
||||
const statArtists = document.getElementById('statArtists');
|
||||
const statAlbums = document.getElementById('statAlbums');
|
||||
const statSongs = document.getElementById('statSongs');
|
||||
const statPlaytime = document.getElementById('statPlaytime');
|
||||
const statQueue = document.getElementById('statQueue');
|
||||
const fileFormat = document.getElementById('fileFormat');
|
||||
const fileQuality = document.getElementById('fileQuality');
|
||||
const fileBitrate = document.getElementById('fileBitrate');
|
||||
|
||||
// Codecs whose bitstream is mathematically lossless — drives the bright
|
||||
// FORMAT highlight in the FILE block.
|
||||
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
|
||||
|
||||
// "44100" → "44.1 kHz", "96000" → "96 kHz". Trim the decimal when the rate
|
||||
// is a clean multiple of 1000 so common rates render as "44.1/48/96 kHz".
|
||||
function fmtSampleRate(hz) {
|
||||
if (!hz || !isFinite(hz) || hz <= 0) return null;
|
||||
const khz = hz / 1000;
|
||||
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
|
||||
return `${s} kHz`;
|
||||
}
|
||||
|
||||
// Quality cell merges sample rate + bit depth: "96 kHz · 24-bit". Either
|
||||
// half can be missing (DSD streams report bits='dsd64' which we drop, MP3s
|
||||
// sometimes lack a clean rate at start of decode).
|
||||
function fmtQuality(audio) {
|
||||
const sr = fmtSampleRate(audio.samplerate);
|
||||
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
|
||||
? `${audio.bits}-bit` : null;
|
||||
if (sr && bits) return `${sr} · ${bits}`;
|
||||
return sr || bits || null;
|
||||
}
|
||||
|
||||
const fmtCount = n => (typeof n === 'number' && isFinite(n))
|
||||
? n.toLocaleString('en-US') : null;
|
||||
|
||||
// Set a metadata cell. Empty/missing → dim em-dash so the row stays
|
||||
// present (otherwise the grid collapses and the layout dances).
|
||||
function setMeta(el, value) {
|
||||
if (value && String(value).trim()) {
|
||||
el.textContent = value;
|
||||
el.classList.remove('empty');
|
||||
} else {
|
||||
el.textContent = '—';
|
||||
el.classList.add('empty');
|
||||
}
|
||||
}
|
||||
|
||||
let lastCoverHash = undefined;
|
||||
function showArt(path, hash) {
|
||||
if (hash === lastCoverHash) return;
|
||||
lastCoverHash = hash;
|
||||
if (!path || !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 = `${path}?v=${hash}`;
|
||||
}
|
||||
|
||||
// ── State application ──
|
||||
// Dedupe by file URI, not s.index — MPD's status.song shifts mid-playback
|
||||
// when the queue is reordered (consume mode, mpc shuffle, etc.), which
|
||||
// would otherwise cause duplicate "now: …" lines for one playthrough.
|
||||
let lastTrackFile = null;
|
||||
function applyState(s) {
|
||||
pendingState = s;
|
||||
if (!bootStarted) {
|
||||
bootFromState(s);
|
||||
return;
|
||||
}
|
||||
if (!bootFinished) return;
|
||||
renderState(s);
|
||||
trackModeChanges(s.playback);
|
||||
}
|
||||
|
||||
function setOffline(msg) {
|
||||
npTitle.textContent = msg;
|
||||
npTime.textContent = '[--:-- / --:--]';
|
||||
}
|
||||
|
||||
let obs = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforePins('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
|
||||
setOffline('daemon disconnected');
|
||||
setTimeout(connectMusic, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', applyState);
|
||||
} catch (e) {
|
||||
logBeforePins(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
|
||||
setTimeout(connectMusic, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Boot sequence — same shape as music-box/index.html so the two scenes
|
||||
// feel like the same machine in different windows.
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
logBeforePins('[audio] waiting for mpd state', 'term-dim', null);
|
||||
await sleep(280);
|
||||
connectMusic();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
315
archived/scenes/music-box/widget.html
Normal file
315
archived/scenes/music-box/widget.html
Normal file
@@ -0,0 +1,315 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Music Box widget</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #04120a;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.45);
|
||||
--frame: rgba(79, 210, 255, 0.85);
|
||||
--frame-glow: rgba(79, 210, 255, 0.30);
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--term-fg);
|
||||
font-family: var(--mono);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Body == widget panel == terminal. Authored at native 549×880; the OBS
|
||||
browser_source must match (bounds_type 0, no stretch). */
|
||||
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;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.terminal {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 22px 16px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--term-fg);
|
||||
}
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-line { word-break: break-word; }
|
||||
.term-prompt { color: var(--term-fg-bright); }
|
||||
.term-out { color: var(--term-fg); }
|
||||
.term-dim { color: var(--term-fg-dim); }
|
||||
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.18) 3px,
|
||||
rgba(0,0,0,0.18) 4px
|
||||
);
|
||||
opacity: 0.45;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* ───────── pinned bottom block ───────── */
|
||||
.pin-spacer { height: 10px; }
|
||||
.np-line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.np-line .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
flex: 0 0 auto;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.np-line .np-title {
|
||||
color: var(--term-fg-bright);
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.np-line .np-time {
|
||||
color: var(--term-fg-dim);
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
.next-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: 14px;
|
||||
}
|
||||
.next-line .next-arrow { color: var(--term-fg-dim); flex: 0 0 auto; }
|
||||
.next-line .next-label { color: var(--term-fg-dim); flex: 0 0 auto; }
|
||||
.next-line .next-title {
|
||||
color: var(--term-fg);
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.next-line .next-title.empty { color: var(--term-fg-dim); }
|
||||
.chat-line {
|
||||
font-size: 13px;
|
||||
color: var(--term-fg-dim);
|
||||
letter-spacing: 0.06em;
|
||||
border-top: 1px solid rgba(95, 220, 98, 0.18);
|
||||
padding-top: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.chat-line .cmd { color: var(--term-fg-bright); }
|
||||
</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">NOW PLAYING</span>
|
||||
<span class="right">CH 118.0 // MUSIC</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<!-- Static pinned block — JS only updates text content. If JS fails,
|
||||
the structure is still visible (better than an empty panel). -->
|
||||
<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…</span><span class="np-time">[--:-- / --:--]</span>
|
||||
</div>
|
||||
<div class="term-line chat-line">
|
||||
chat: <span class="cmd">!skip</span> · <span class="cmd">!queue</span> · <span class="cmd">!info</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scanlines"></div>
|
||||
|
||||
<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');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const fmtClock = s => (!isFinite(s) || s < 0)
|
||||
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
|
||||
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 MAX_TERM_LINES = 60;
|
||||
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();
|
||||
}
|
||||
|
||||
// Dedupe by file URI, not s.index — see music-box/index.html for context.
|
||||
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 = '[--:-- / --:--]';
|
||||
}
|
||||
|
||||
let obs = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforePins('config missing', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
logBeforePins('daemon disconnected', 'term-dim');
|
||||
setOffline('disconnected');
|
||||
setTimeout(connectMusic, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', applyState);
|
||||
} catch (e) {
|
||||
logBeforePins('daemon unreachable', 'term-dim');
|
||||
setTimeout(connectMusic, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await sleep(200);
|
||||
logBeforePins('[audio] subscribing to mpd:state', 'term-out');
|
||||
await sleep(180);
|
||||
logBeforePins('[audio] queue: shuffle on', 'term-out');
|
||||
await sleep(180);
|
||||
logBeforePins('[audio] standby ✓', 'term-out');
|
||||
await sleep(220);
|
||||
connectMusic();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
228
archived/scenes/music/index.html
Normal file
228
archived/scenes/music/index.html
Normal file
@@ -0,0 +1,228 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / Music Daemon</title>
|
||||
<style>
|
||||
/* Daemon page — visible if the source is set visible in OBS, otherwise just
|
||||
a hidden audio worker. The visible state block is for diagnostics: toggle
|
||||
the source's eye-icon ON briefly to read it, OFF when everything works. */
|
||||
html, body { margin: 0; padding: 0; background: #0a0e0a; overflow: hidden; color: #5fdc62;
|
||||
font: 14px/1.45 'DejaVu Sans Mono', 'Consolas', monospace; }
|
||||
#status { padding: 12px 14px; }
|
||||
#status h1 { font-size: 11px; letter-spacing: 0.25em; color: #2c8d2f; margin: 0 0 8px; font-weight: 700; }
|
||||
#status .row { display: flex; gap: 10px; margin: 2px 0; }
|
||||
#status .k { color: #2c8d2f; min-width: 70px; }
|
||||
#status .v { color: #97f99a; word-break: break-all; }
|
||||
#status .err { color: #ff6f5a; }
|
||||
#status .ok { color: #97f99a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="status">
|
||||
<h1>OPHI MUSIC DAEMON</h1>
|
||||
<div class="row"><span class="k">ws</span><span class="v" id="s-ws">init…</span></div>
|
||||
<div class="row"><span class="k">queue</span><span class="v" id="s-queue">—</span></div>
|
||||
<div class="row"><span class="k">now</span><span class="v" id="s-now">—</span></div>
|
||||
<div class="row"><span class="k">audio</span><span class="v" id="s-audio">—</span></div>
|
||||
<div class="row"><span class="k">last cmd</span><span class="v" id="s-cmd">—</span></div>
|
||||
</div>
|
||||
|
||||
<audio id="player" preload="auto"></audio>
|
||||
|
||||
<script src="../vendor/obs-config.js"></script>
|
||||
<script src="../vendor/obs-ws-mini.js"></script>
|
||||
<script src="../loading/playlist.js"></script>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// ─────────────── State ───────────────
|
||||
const data = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = data.tracks || [];
|
||||
const audio = document.getElementById('player');
|
||||
audio.volume = 1.0;
|
||||
|
||||
// Fisher-Yates shuffle, fresh every load.
|
||||
function shuffle(a) {
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const queue = shuffle([...allTracks]);
|
||||
let qIdx = 0;
|
||||
let stallTimer = null;
|
||||
let _watchT = 0, _watchAt = Date.now();
|
||||
|
||||
// ─────────────── Visible status block (debug aid) ───────────────
|
||||
// Must come AFTER `queue`/`qIdx` are declared — refreshStatus() reads them.
|
||||
const $ws = document.getElementById('s-ws');
|
||||
const $q = document.getElementById('s-queue');
|
||||
const $n = document.getElementById('s-now');
|
||||
const $a = document.getElementById('s-audio');
|
||||
const $c = document.getElementById('s-cmd');
|
||||
function setWs(text, cls = '') { $ws.textContent = text; $ws.className = 'v ' + cls; }
|
||||
function refreshStatus() {
|
||||
$q.textContent = window.__PLAYLIST
|
||||
? `${allTracks.length} tracks (playlist.js loaded)`
|
||||
: 'playlist.js NOT loaded';
|
||||
const t = allTracks.length ? `#${qIdx + 1}/${allTracks.length} — ${queue[qIdx]?.title || '?'}` : '—';
|
||||
$n.textContent = t;
|
||||
let aState = 'idle';
|
||||
if (audio.paused) aState = 'paused';
|
||||
else if (audio.ended) aState = 'ended';
|
||||
else if (audio.src) aState = `playing (${audio.currentTime.toFixed(0)}s / ${(audio.duration||0).toFixed(0)}s)`;
|
||||
$a.textContent = aState;
|
||||
}
|
||||
setInterval(refreshStatus, 500);
|
||||
refreshStatus();
|
||||
|
||||
function clearStall() { clearTimeout(stallTimer); stallTimer = null; }
|
||||
|
||||
// ─────────────── OBS WebSocket connection ───────────────
|
||||
let obs = null;
|
||||
async function connectOBS() {
|
||||
if (!window.__OBSWS) {
|
||||
setWs('vendor/obs-config.js MISSING — run scripts/setup.sh', 'err');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setWs(`connecting → ${window.__OBSWS.url}`);
|
||||
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
setWs('identified ✓', 'ok');
|
||||
obs.addEventListener('close', () => {
|
||||
setWs('closed — retrying in 2s', 'err');
|
||||
setTimeout(connectOBS, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:cmd', (d) => handleCommand(d));
|
||||
broadcastState();
|
||||
} catch (e) {
|
||||
setWs(`connect failed (${e.message}) — retrying`, 'err');
|
||||
setTimeout(connectOBS, 2500);
|
||||
}
|
||||
}
|
||||
connectOBS();
|
||||
|
||||
// ─────────────── Broadcasting ───────────────
|
||||
// Single 'mpd:state' event for everything. Listeners diff `index` to detect
|
||||
// track changes; this means newly-connecting overlays get full state within
|
||||
// ~1s of connecting (no special request/response handshake needed).
|
||||
function broadcastState() {
|
||||
if (!obs || !obs.identified) return;
|
||||
const t = queue[qIdx];
|
||||
obs.broadcast('mpd:state', {
|
||||
index: qIdx,
|
||||
total: queue.length,
|
||||
title: t?.title || null,
|
||||
file: t?.file || null,
|
||||
currentTime: audio.currentTime || 0,
|
||||
duration: audio.duration || t?.durationSec || 0,
|
||||
paused: audio.paused,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ─────────────── Playback ───────────────
|
||||
function playCurrent() {
|
||||
if (queue.length === 0) return;
|
||||
qIdx = ((qIdx % queue.length) + queue.length) % queue.length;
|
||||
const t = queue[qIdx];
|
||||
clearStall();
|
||||
audio.src = t.file;
|
||||
stallTimer = setTimeout(() => {
|
||||
console.warn('[music] start timeout, skipping', t.title);
|
||||
advance(+1);
|
||||
}, 15000);
|
||||
audio.play().catch(() => {
|
||||
clearStall();
|
||||
console.warn('[music] play() rejected, skipping', t.title);
|
||||
advance(+1);
|
||||
});
|
||||
broadcastState();
|
||||
}
|
||||
|
||||
function advance(direction) {
|
||||
clearStall();
|
||||
if (queue.length === 0) return;
|
||||
qIdx = (qIdx + direction + queue.length) % queue.length;
|
||||
playCurrent();
|
||||
}
|
||||
|
||||
audio.addEventListener('ended', () => advance(+1));
|
||||
audio.addEventListener('error', () => { console.warn('[music] decode error'); advance(+1); });
|
||||
audio.addEventListener('playing', clearStall);
|
||||
|
||||
// Mid-track stall watchdog (currentTime not advancing while expected to play).
|
||||
setInterval(() => {
|
||||
if (audio.paused || audio.ended || !audio.src) {
|
||||
_watchT = audio.currentTime;
|
||||
_watchAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(audio.currentTime - _watchT) >= 0.05) {
|
||||
_watchT = audio.currentTime;
|
||||
_watchAt = Date.now();
|
||||
} else if (Date.now() - _watchAt > 8000) {
|
||||
console.warn('[music] mid-track stall, skipping');
|
||||
_watchT = 0; _watchAt = Date.now();
|
||||
advance(+1);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Periodic state broadcast — 1 Hz is enough for the visible time display
|
||||
// and ensures any newly-connecting overlay catches up within a second.
|
||||
setInterval(broadcastState, 1000);
|
||||
|
||||
// ─────────────── Command sources ───────────────
|
||||
// 1) Bash CLI: writes loading/cmd.js → polled here via cache-busted <script>.
|
||||
// First poll after page-load ignores the existing command (it was already
|
||||
// consumed in the previous session).
|
||||
let lastCmdId = null;
|
||||
let firstCmdPoll = true;
|
||||
function pollCmd() {
|
||||
const old = document.getElementById('cmd-poll');
|
||||
if (old) old.remove();
|
||||
const s = document.createElement('script');
|
||||
s.id = 'cmd-poll';
|
||||
s.src = '../loading/cmd.js?t=' + Date.now();
|
||||
s.onload = () => {
|
||||
const cmd = window.__CMD;
|
||||
if (!cmd) return;
|
||||
if (firstCmdPoll) { lastCmdId = cmd.id; firstCmdPoll = false; return; }
|
||||
if (cmd.id !== lastCmdId) {
|
||||
lastCmdId = cmd.id;
|
||||
handleCommand(cmd);
|
||||
}
|
||||
};
|
||||
s.onerror = () => { firstCmdPoll = false; }; // file doesn't exist yet — fine
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
setInterval(pollCmd, 250);
|
||||
pollCmd();
|
||||
|
||||
// 2) OBS WebSocket: any source can send mpd:cmd via OBSWSMini.broadcast().
|
||||
function handleCommand(cmd) {
|
||||
const type = (cmd?.type || '').toLowerCase();
|
||||
$c.textContent = `${type} @ ${new Date().toLocaleTimeString()}`;
|
||||
switch (type) {
|
||||
case 'skip': advance(+1); break;
|
||||
case 'prev': advance(-1); break;
|
||||
case 'pause': audio.pause(); broadcastState(); break;
|
||||
case 'resume': audio.play().catch(() => {}); broadcastState(); break;
|
||||
default:
|
||||
console.warn('[music] unknown command:', type);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Boot ───────────────
|
||||
if (queue.length > 0) {
|
||||
playCurrent();
|
||||
} else {
|
||||
console.warn('[music] no tracks — run scripts/playlist.sh');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
171
archived/scripts/loading.sh
Executable file
171
archived/scripts/loading.sh
Executable file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# Interactive manifest builder for the Project Loading scene.
|
||||
#
|
||||
# ./loading.sh
|
||||
#
|
||||
# Walks you through 5 prompts, writes loading.json + loading.js, then
|
||||
# refresh the Project Loading browser source in OBS. Re-running uses
|
||||
# previous answers as defaults — press Enter to keep them.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
JSON="$OBS_DIR/loading/loading.json"
|
||||
JS="$OBS_DIR/loading/loading.js"
|
||||
|
||||
# Reads a top-level field from the previous loading.json. Always exits 0;
|
||||
# prints empty string if file/field is missing.
|
||||
prev() {
|
||||
[[ -f "$JSON" ]] || return 0
|
||||
python3 - "$JSON" "$1" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
v = d.get(sys.argv[2])
|
||||
if v is None: print('')
|
||||
elif isinstance(v, bool): print('y' if v else 'n')
|
||||
else: print(v)
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
}
|
||||
|
||||
ask() {
|
||||
# ask "question" "default" → echoes user input or default if blank
|
||||
local q="$1" def="${2:-}"
|
||||
local prompt=" $q"
|
||||
[[ -n "$def" ]] && prompt+=" [$def]"
|
||||
prompt+=": "
|
||||
local v
|
||||
read -rp "$prompt" v
|
||||
[[ -z "$v" && -n "$def" ]] && v="$def"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
ask_yn() {
|
||||
local v; v=$(ask "$1" "${2:-y}")
|
||||
case "${v,,}" in
|
||||
y|yes|on|true|1) printf 'true' ;;
|
||||
n|no|off|false|0) printf 'false' ;;
|
||||
*) printf 'true' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ask_int() {
|
||||
local v
|
||||
while :; do
|
||||
v=$(ask "$1" "${2:-}")
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
|
||||
echo " ✗ must be a whole number" >&2
|
||||
done
|
||||
}
|
||||
|
||||
sanitize() {
|
||||
printf '%s' "${1:-}" \
|
||||
| tr -d '"\\' \
|
||||
| tr '\n\r\t' ' ' \
|
||||
| sed 's/ */ /g; s/^ //; s/ $//'
|
||||
}
|
||||
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
|
||||
|
||||
# ── Pull previous answers as defaults ───────────────
|
||||
prev_game=$(prev game)
|
||||
prev_game_id=$(prev gameId)
|
||||
prev_subtitle=$(prev subtitle)
|
||||
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
|
||||
prev_camera=$(prev camera); [[ -z "$prev_camera" ]] && prev_camera="y"
|
||||
prev_mic=$(prev microphone); [[ -z "$prev_mic" ]] && prev_mic="y"
|
||||
|
||||
echo "── Project Loading manifest ──"
|
||||
echo " (press Enter to keep [defaults])"
|
||||
echo
|
||||
|
||||
# Resolve Game via Twitch search → exact directory entry. The picker prints
|
||||
# "<id>\t<name>" to stdout on success, or non-zero on no-match / cancel.
|
||||
# Re-running and accepting the previous game unchanged reuses the cached id
|
||||
# so we don't burn an API call to re-resolve a known answer.
|
||||
SEARCH_GAME="$OBS_DIR/twitch-bot/search-game.py"
|
||||
ENV_FILE="$OBS_DIR/twitch-bot/.env.ophi118"
|
||||
have_twitch=0
|
||||
[[ -x "$SEARCH_GAME" && -f "$ENV_FILE" ]] && have_twitch=1
|
||||
|
||||
game=""; game_id=""
|
||||
while :; do
|
||||
query=$(ask "Game (search)" "$prev_game")
|
||||
if [[ -z "$query" ]]; then
|
||||
echo " ✗ Game is required" >&2
|
||||
continue
|
||||
fi
|
||||
if (( have_twitch )); then
|
||||
# Reuse cached id only if it looks like a real Twitch numeric id. A past
|
||||
# bug (search-game.py prompt leaking into stdout) wrote "Pick: 506462"
|
||||
# here; the regex makes sure such corruption falls back to a fresh search
|
||||
# instead of getting passed straight to PATCH /helix/channels.
|
||||
if [[ -n "$prev_game_id" && "$query" == "$prev_game" && "$prev_game_id" =~ ^[0-9]+$ ]]; then
|
||||
game="$prev_game"; game_id="$prev_game_id"
|
||||
echo " ↻ reusing cached: $game (id=$game_id)"
|
||||
break
|
||||
fi
|
||||
if line=$("$SEARCH_GAME" "$query"); then
|
||||
IFS=$'\t' read -r game_id game <<<"$line"
|
||||
break
|
||||
fi
|
||||
# search-game.py already printed its own error; loop and re-ask
|
||||
else
|
||||
# no twitch helper available — accept the raw input, no id resolution
|
||||
game="$query"; game_id=""
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
|
||||
countdown=$(ask_int "Countdown (minutes)" "$prev_count")
|
||||
camera=$(ask_yn "Camera" "$prev_camera")
|
||||
mic=$(ask_yn "Microphone" "$prev_mic")
|
||||
|
||||
game=$(sanitize "$game")
|
||||
subtitle=$(sanitize "$subtitle")
|
||||
|
||||
now_iso=$(date -u +%FT%TZ)
|
||||
tmp="$JSON.tmp.$$"
|
||||
cat > "$tmp" <<JSON
|
||||
{
|
||||
"compiledAt": "$now_iso",
|
||||
"game": $(emit_str "$game"),
|
||||
"gameId": $(emit_str "$game_id"),
|
||||
"subtitle": $(emit_str "$subtitle"),
|
||||
"countdownMin": $countdown,
|
||||
"camera": $camera,
|
||||
"microphone": $mic
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
|
||||
echo " ✗ produced invalid JSON, leaving $tmp for inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp" "$JSON"
|
||||
{ printf 'window.__LOADING = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
echo
|
||||
echo " ✓ wrote $JSON"
|
||||
echo " ✓ wrote $JS"
|
||||
|
||||
# ── Push to Twitch (game + title) via the broadcaster token ─────────────
|
||||
# Soft-fail: a Twitch hiccup must not block the local manifest from being
|
||||
# written — the overlay can still load offline. set-channel.py reads
|
||||
# ../twitch-bot/.env.ophi118 (channel:manage:broadcast scope required).
|
||||
# We pass --game-id so set-channel.py skips its own (exact-match) lookup —
|
||||
# the id is already resolved by the search step above.
|
||||
SET_CHANNEL="$OBS_DIR/twitch-bot/set-channel.py"
|
||||
if (( have_twitch )) && [[ -x "$SET_CHANNEL" && -n "$game_id" ]]; then
|
||||
twitch_title="${subtitle:-$game}"
|
||||
echo
|
||||
if ! "$SET_CHANNEL" --game-id "$game_id" "$twitch_title"; then
|
||||
echo " ! Twitch sync failed (manifest still saved) — fix and re-run if needed" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo " → refresh the Project Loading browser source in OBS."
|
||||
185
archived/scripts/playlist.sh
Executable file
185
archived/scripts/playlist.sh
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/playlist.sh — playlist sync + control API for the music daemon.
|
||||
#
|
||||
# Usage:
|
||||
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
||||
# ./playlist.sh sync → explicit sync
|
||||
# ./playlist.sh skip → next track
|
||||
# ./playlist.sh prev → previous track
|
||||
# ./playlist.sh pause → pause playback
|
||||
# ./playlist.sh resume → resume playback
|
||||
# ./playlist.sh status → show last queued command
|
||||
# ./playlist.sh help → this help
|
||||
#
|
||||
# Control commands (skip/prev/pause/resume) write a tiny `cmd.js` that the
|
||||
# Music Daemon polls every 250ms. The daemon picks up the new command,
|
||||
# acts on it, and broadcasts the resulting state via OBS WebSocket so the
|
||||
# loading overlay updates live.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
JSON="$OBS_DIR/loading/playlist.json"
|
||||
JS="$OBS_DIR/loading/playlist.js"
|
||||
CMD_JS="$OBS_DIR/loading/cmd.js"
|
||||
|
||||
# Audio roots — scanned recursively, in order. Add more here as the library grows.
|
||||
# Missing roots are skipped with a warning, not an error.
|
||||
ROOTS=(
|
||||
"$OBS_DIR/playlist"
|
||||
"$HOME/HDD/Music/Electronic/NCS Directory"
|
||||
)
|
||||
|
||||
usage() {
|
||||
sed -n 's/^# \?//p' "$0" | head -20
|
||||
}
|
||||
|
||||
# ─────────────── sync ───────────────
|
||||
cmd_sync() {
|
||||
if ! command -v ffprobe >/dev/null 2>&1; then
|
||||
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── Indexing audio roots ──"
|
||||
for r in "${ROOTS[@]}"; do
|
||||
if [[ -d "$r" ]]; then echo " + $r"; else echo " ! $r (missing — skipped)"; fi
|
||||
done
|
||||
|
||||
local TMP="$JSON.tmp.$$"
|
||||
python3 - "${ROOTS[@]}" "$TMP" <<'PY'
|
||||
import json, subprocess, sys, os, datetime, re, urllib.parse
|
||||
|
||||
roots = sys.argv[1:-1]
|
||||
out = sys.argv[-1]
|
||||
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
|
||||
|
||||
def ffprobe_meta(path):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||||
'-of', 'json', path],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0: return {}
|
||||
fmt = (json.loads(r.stdout) or {}).get('format', {}) or {}
|
||||
tags = {k.lower(): v for k, v in (fmt.get('tags') or {}).items()}
|
||||
return {
|
||||
'title': tags.get('title'),
|
||||
'artist': tags.get('artist'),
|
||||
'durationSec': float(fmt['duration']) if fmt.get('duration') else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def clean_title(t):
|
||||
if not t: return t
|
||||
# PRIMARY rule: keep only what's before the first vertical bar
|
||||
# (fullwidth | U+FF5C or ASCII |). Everything after is genre/label noise.
|
||||
t = re.sub(r'\s*[||].*$', '', t)
|
||||
# Strip trailing YouTube-ID brackets, e.g. " [-XxZTgMWKV0]"
|
||||
t = re.sub(r'\s*\[[A-Za-z0-9_-]{11}\]\s*$', '', t)
|
||||
# Strip "[NCS Release]" / "(NCS10 Release)" suffix variants
|
||||
t = re.sub(r'\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$', '', t, flags=re.I)
|
||||
return t.strip()
|
||||
|
||||
INTERMEDIATE = re.compile(r'\.(?:f\d+|temp|part)$', re.I)
|
||||
def is_audio_file(name):
|
||||
base, ext = os.path.splitext(name)
|
||||
if ext.lower() not in exts: return False
|
||||
if INTERMEDIATE.search(base): return False
|
||||
return True
|
||||
|
||||
# Absolute file:// URI — works across roots since the daemon HTML and the audio
|
||||
# may live on different filesystems. CEF in OBS browser source loads these
|
||||
# directly thanks to the Flatpak's filesystems=host permission.
|
||||
def file_uri(path):
|
||||
return 'file://' + urllib.parse.quote(os.path.abspath(path), safe='/')
|
||||
|
||||
tracks = []
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
print(f' ! skipping missing root: {root}', file=sys.stderr)
|
||||
continue
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for fname in sorted(files):
|
||||
if not is_audio_file(fname): continue
|
||||
path = os.path.join(dirpath, fname)
|
||||
meta = ffprobe_meta(path)
|
||||
display = clean_title(os.path.splitext(fname)[0])
|
||||
tracks.append({
|
||||
'title': display,
|
||||
'file': file_uri(path),
|
||||
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
||||
})
|
||||
|
||||
doc = {
|
||||
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'sourceDirs': roots,
|
||||
'trackCount': len(tracks),
|
||||
'tracks': tracks,
|
||||
}
|
||||
with open(out, 'w') as f:
|
||||
json.dump(doc, f, indent=2, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
if ! python3 -m json.tool "$TMP" >/dev/null 2>&1; then
|
||||
echo " ✗ manifest invalid, leaving $TMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$TMP" "$JSON"
|
||||
{ printf 'window.__PLAYLIST = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
local count
|
||||
count=$(python3 -c "import json; print(json.load(open('$JSON'))['trackCount'])")
|
||||
echo
|
||||
echo " ✓ wrote $JSON ($count tracks)"
|
||||
echo " ✓ wrote $JS"
|
||||
echo " → refresh the Music Daemon browser source in OBS to reload the queue."
|
||||
}
|
||||
|
||||
# ─────────────── control commands ───────────────
|
||||
# Writes a unique-per-call cmd.js that the daemon polls. The id is
|
||||
# nanosecond-precise so the daemon's "did this id change?" check always passes
|
||||
# even on rapid-fire commands.
|
||||
send_cmd() {
|
||||
local type="$1"
|
||||
local id ts
|
||||
id=$(date +%s%N)
|
||||
ts=$(date +%s)
|
||||
printf 'window.__CMD = {"id":%s,"type":"%s","ts":%s};\n' "$id" "$type" "$ts" > "$CMD_JS"
|
||||
echo " → ${type} (id=$id)"
|
||||
}
|
||||
|
||||
cmd_skip() { send_cmd skip; }
|
||||
cmd_prev() { send_cmd prev; }
|
||||
cmd_pause() { send_cmd pause; }
|
||||
cmd_resume() { send_cmd resume; }
|
||||
|
||||
cmd_status() {
|
||||
if [[ -f "$CMD_JS" ]]; then
|
||||
echo "── last queued command ──"
|
||||
cat "$CMD_JS"
|
||||
else
|
||||
echo " (no command file yet — daemon hasn't been signalled this session)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────── dispatch ───────────────
|
||||
case "${1:-sync}" in
|
||||
sync) cmd_sync ;;
|
||||
skip|next) cmd_skip ;;
|
||||
prev|back) cmd_prev ;;
|
||||
pause) cmd_pause ;;
|
||||
resume|play) cmd_resume ;;
|
||||
status) cmd_status ;;
|
||||
help|-h|--help) usage ;;
|
||||
*)
|
||||
echo " ✗ unknown command: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
57
archived/scripts/setup.sh
Executable file
57
archived/scripts/setup.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup: builds vendor/obs-config.js from your existing OBS WebSocket
|
||||
# plugin config so the overlay/daemon pages can connect.
|
||||
#
|
||||
# bash scripts/setup.sh
|
||||
#
|
||||
# Re-run if you change the WebSocket port or password in OBS.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
SRC="$OBS_DIR/plugin_config/obs-websocket/config.json"
|
||||
OUT="$OBS_DIR/vendor/obs-config.js"
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo " ✗ $SRC not found." >&2
|
||||
echo " Open OBS once with the obs-websocket plugin loaded so it generates the config." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull port + password out of the JSON
|
||||
read -r PORT PASS ENABLED <<<"$(python3 -c "
|
||||
import json, sys
|
||||
d = json.load(open('$SRC'))
|
||||
print(d.get('server_port', 4455), d.get('server_password', ''), str(d.get('server_enabled', False)).lower())
|
||||
")"
|
||||
|
||||
mkdir -p "$OBS_DIR/vendor"
|
||||
cat > "$OUT" <<JS
|
||||
// Auto-generated by scripts/setup.sh — gitignored. Reflects the current contents
|
||||
// of plugin_config/obs-websocket/config.json. Re-run scripts/setup.sh if it changes.
|
||||
window.__OBSWS = {
|
||||
url: 'ws://localhost:$PORT',
|
||||
password: '$PASS',
|
||||
};
|
||||
JS
|
||||
|
||||
echo " ✓ wrote $OUT"
|
||||
echo " url = ws://localhost:$PORT"
|
||||
if [[ -z "$PASS" ]]; then
|
||||
echo " password = (none)"
|
||||
else
|
||||
echo " password = (set, ${#PASS} chars)"
|
||||
fi
|
||||
|
||||
# Check the live socket, not the config file — OBS lags writes to plugin_config
|
||||
# until shutdown, so server_enabled in JSON often disagrees with reality.
|
||||
echo
|
||||
if ss -lnt 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo " ✓ OBS WebSocket server is listening on :$PORT"
|
||||
elif nc -z localhost "$PORT" 2>/dev/null; then
|
||||
echo " ✓ OBS WebSocket server is listening on :$PORT"
|
||||
else
|
||||
echo " ⚠ Nothing listening on :$PORT yet."
|
||||
echo " In OBS: Tools → WebSocket Server Settings → ✅ Enable WebSocket server → OK"
|
||||
echo " (config.json may already say 'enabled: $ENABLED' — that file lags actual state)"
|
||||
fi
|
||||
336
archived/scripts/telemetry.sh
Executable file
336
archived/scripts/telemetry.sh
Executable file
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static device + OBS config snapshot for the OBS Landing overlay.
|
||||
# Output JSON also carries the `rig` field consumed by the Project Loading
|
||||
# overlay's "detected rig :: …" line.
|
||||
#
|
||||
# Modes:
|
||||
# ./telemetry.sh — wrap telemetry.json → telemetry.js
|
||||
# (idempotent; preserves hand edits to JSON)
|
||||
# ./telemetry.sh --collect — re-read hardware + OBS + hostname,
|
||||
# interactively review every field, then wrap
|
||||
# ./telemetry.sh --collect --no-review
|
||||
# — collect, skip prompts (unattended)
|
||||
#
|
||||
# Use --collect after a hardware change, kernel update, or OBS settings change.
|
||||
# For cosmetic tweaks just edit telemetry.json by hand and re-run with no args.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
JSON="$OBS_DIR/landing/telemetry.json"
|
||||
JS="$OBS_DIR/landing/telemetry.js"
|
||||
|
||||
sanitize() {
|
||||
printf '%s' "${1:-}" \
|
||||
| tr -d '"\\' \
|
||||
| tr '\n\r\t' ' ' \
|
||||
| sed 's/ */ /g; s/^ //; s/ $//'
|
||||
}
|
||||
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
|
||||
emit_num() { [[ -z "${1:-}" ]] && printf 'null' || printf '%s' "$1"; }
|
||||
|
||||
# ── INI reader (section-scoped) ─────────────────────
|
||||
ini_get() {
|
||||
# Usage: ini_get FILE SECTION KEY → prints value (empty if missing)
|
||||
local file="$1" section="$2" key="$3"
|
||||
[[ -f "$file" ]] || return 0
|
||||
awk -v section="[$section]" -v key="$key" '
|
||||
$0 == section { in_section = 1; next }
|
||||
/^\[/ { in_section = 0; next }
|
||||
in_section && index($0, key"=") == 1 {
|
||||
sub("^[^=]*=", "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
# Friendlier names for the encoder ids OBS stores in basic.ini
|
||||
prettify_encoder() {
|
||||
case "${1:-}" in
|
||||
obs_x264|x264) echo "x264";;
|
||||
jim_nvenc|ffmpeg_nvenc|obs_nvenc_h264_tex|obs_nvenc_h264_soft) echo "NVENC H.264";;
|
||||
obs_nvenc_hevc_tex|obs_nvenc_hevc_soft) echo "NVENC HEVC";;
|
||||
obs_nvenc_av1_tex|obs_nvenc_av1_soft) echo "NVENC AV1";;
|
||||
obs_qsv11) echo "QSV";;
|
||||
*) echo "${1:-}";;
|
||||
esac
|
||||
}
|
||||
|
||||
# Pull a top-level field out of a flat JSON object via python3.
|
||||
json_field() {
|
||||
local file="$1" field="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
python3 - "$file" "$field" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
d = json.load(f)
|
||||
v = d.get(sys.argv[2])
|
||||
print('' if v is None else v)
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
}
|
||||
|
||||
# ── OBS config block ────────────────────────────────
|
||||
collect_obs() {
|
||||
local user_ini="$OBS_DIR/user.ini"
|
||||
[[ -f "$user_ini" ]] || { echo "null"; return; }
|
||||
|
||||
local profile renderer
|
||||
profile=$(ini_get "$user_ini" "Basic" "Profile")
|
||||
renderer=$(ini_get "$user_ini" "Video" "Renderer")
|
||||
[[ -z "$profile" ]] && profile="Untitled"
|
||||
|
||||
local profile_dir="$OBS_DIR/basic/profiles/$profile"
|
||||
local ini="$profile_dir/basic.ini"
|
||||
[[ -f "$ini" ]] || { echo "null"; return; }
|
||||
|
||||
local mode base_w base_h out_w out_h fps
|
||||
mode=$(ini_get "$ini" "Output" "Mode")
|
||||
base_w=$(ini_get "$ini" "Video" "BaseCX")
|
||||
base_h=$(ini_get "$ini" "Video" "BaseCY")
|
||||
out_w=$(ini_get "$ini" "Video" "OutputCX")
|
||||
out_h=$(ini_get "$ini" "Video" "OutputCY")
|
||||
fps=$(ini_get "$ini" "Video" "FPSInt")
|
||||
|
||||
local color_fmt color_space color_range
|
||||
color_fmt=$(ini_get "$ini" "Video" "ColorFormat")
|
||||
color_space=$(ini_get "$ini" "Video" "ColorSpace")
|
||||
color_range=$(ini_get "$ini" "Video" "ColorRange")
|
||||
|
||||
local sample_rate ch_setup
|
||||
sample_rate=$(ini_get "$ini" "Audio" "SampleRate")
|
||||
ch_setup=$(ini_get "$ini" "Audio" "ChannelSetup")
|
||||
|
||||
# Stream encoder + bitrate. Path differs Simple vs Advanced.
|
||||
local enc_raw="" bitrate="" rc="" keyint="" h264_profile=""
|
||||
if [[ "$mode" == "Advanced" ]]; then
|
||||
enc_raw=$(ini_get "$ini" "AdvOut" "Encoder")
|
||||
local sej="$profile_dir/streamEncoder.json"
|
||||
rc=$(json_field "$sej" "rate_control")
|
||||
bitrate=$(json_field "$sej" "bitrate")
|
||||
keyint=$(json_field "$sej" "keyint_sec")
|
||||
h264_profile=$(json_field "$sej" "profile")
|
||||
else
|
||||
enc_raw=$(ini_get "$ini" "SimpleOutput" "StreamEncoder")
|
||||
bitrate=$(ini_get "$ini" "SimpleOutput" "VBitrate")
|
||||
fi
|
||||
local enc_pretty
|
||||
enc_pretty=$(prettify_encoder "$enc_raw")
|
||||
|
||||
profile=$(sanitize "$profile")
|
||||
renderer=$(sanitize "$renderer")
|
||||
mode=$(sanitize "$mode")
|
||||
color_fmt=$(sanitize "$color_fmt")
|
||||
color_space=$(sanitize "$color_space")
|
||||
color_range=$(sanitize "$color_range")
|
||||
ch_setup=$(sanitize "$ch_setup")
|
||||
enc_pretty=$(sanitize "$enc_pretty")
|
||||
rc=$(sanitize "$rc")
|
||||
h264_profile=$(sanitize "$h264_profile")
|
||||
|
||||
cat <<JSON
|
||||
{
|
||||
"profile": $(emit_str "$profile"),
|
||||
"renderer": $(emit_str "$renderer"),
|
||||
"outputMode": $(emit_str "$mode"),
|
||||
"canvas": { "w": $(emit_num "$base_w"), "h": $(emit_num "$base_h") },
|
||||
"output": { "w": $(emit_num "$out_w"), "h": $(emit_num "$out_h") },
|
||||
"fps": $(emit_num "$fps"),
|
||||
"color": {
|
||||
"format": $(emit_str "$color_fmt"),
|
||||
"space": $(emit_str "$color_space"),
|
||||
"range": $(emit_str "$color_range")
|
||||
},
|
||||
"stream": {
|
||||
"encoder": $(emit_str "$enc_pretty"),
|
||||
"rateControl": $(emit_str "$rc"),
|
||||
"bitrateKbps": $(emit_num "$bitrate"),
|
||||
"keyintSec": $(emit_num "$keyint"),
|
||||
"profile": $(emit_str "$h264_profile")
|
||||
},
|
||||
"audio": {
|
||||
"sampleRateHz": $(emit_num "$sample_rate"),
|
||||
"channels": $(emit_str "$ch_setup")
|
||||
}
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
# ── Hardware specs ──────────────────────────────────
|
||||
collect() {
|
||||
local cpu_model cpu_threads
|
||||
cpu_model=$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo)
|
||||
cpu_threads=$(nproc)
|
||||
|
||||
local mem_total_kb mem_total_g
|
||||
mem_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
|
||||
mem_total_g=$(awk -v k="$mem_total_kb" 'BEGIN {printf "%.1f", k/1024/1024}')
|
||||
|
||||
local kernel; kernel=$(uname -r)
|
||||
|
||||
# Rig identity — title-cased hostname (ignia → Ignia, midgolem → Midgolem).
|
||||
# Consumed by the Project Loading overlay's "detected rig :: …" line.
|
||||
local rig; rig=$(hostname)
|
||||
rig="${rig^}"
|
||||
|
||||
local gpu_name="" gpu_vram_total_mb=""
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
local q
|
||||
q=$(nvidia-smi --query-gpu=name,memory.total \
|
||||
--format=csv,noheader,nounits 2>/dev/null | head -n1 || true)
|
||||
if [[ -n "$q" ]]; then
|
||||
IFS=',' read -r gpu_name gpu_vram_total_mb <<<"$q"
|
||||
gpu_name=$(sanitize "$gpu_name")
|
||||
gpu_vram_total_mb=$(printf '%s' "$gpu_vram_total_mb" | tr -d ' ')
|
||||
fi
|
||||
fi
|
||||
|
||||
cpu_model=$(sanitize "$cpu_model")
|
||||
kernel=$(sanitize "$kernel")
|
||||
rig=$(sanitize "$rig")
|
||||
local now_iso; now_iso=$(date -u +%FT%TZ)
|
||||
|
||||
local obs_block; obs_block=$(collect_obs)
|
||||
|
||||
local tmp="$JSON.tmp.$$"
|
||||
cat > "$tmp" <<JSON
|
||||
{
|
||||
"collectedAt": "$now_iso",
|
||||
"rig": $(emit_str "$rig"),
|
||||
"cpu": {
|
||||
"model": $(emit_str "$cpu_model"),
|
||||
"threads": $(emit_num "$cpu_threads")
|
||||
},
|
||||
"mem": {
|
||||
"totalGB": $(emit_num "$mem_total_g")
|
||||
},
|
||||
"host": {
|
||||
"kernel": $(emit_str "$kernel")
|
||||
},
|
||||
"gpu": {
|
||||
"name": $(emit_str "$gpu_name"),
|
||||
"vramTotalMB": $(emit_num "$gpu_vram_total_mb")
|
||||
},
|
||||
"obs": $obs_block
|
||||
}
|
||||
JSON
|
||||
|
||||
# Validate before promoting — catches any malformed substitution.
|
||||
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
|
||||
echo "collect: produced invalid JSON, leaving $tmp for inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp" "$JSON"
|
||||
echo "wrote $JSON"
|
||||
}
|
||||
|
||||
# Walk every leaf in telemetry.json and prompt for a per-field override.
|
||||
# Preserves JSON types (numbers stay numbers, strings stay strings, the
|
||||
# literal word `null` becomes JSON null). Empty input keeps the current value.
|
||||
# Skips `collectedAt` — it's auto-generated.
|
||||
review() {
|
||||
[[ -f "$JSON" ]] || { echo "review: $JSON not found" >&2; exit 1; }
|
||||
echo
|
||||
echo "── Review telemetry fields ─────────────────────────────"
|
||||
echo " Press Enter to keep the value shown."
|
||||
echo " Type a new value to override (use 'null' to clear)."
|
||||
echo " Ctrl-C to abort without writing."
|
||||
echo
|
||||
local tmp="$JSON.review.$$"
|
||||
if ! python3 - "$JSON" "$tmp" <<'PY'
|
||||
import json, sys
|
||||
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
with open(src) as f:
|
||||
doc = json.load(f)
|
||||
|
||||
SKIP = {'collectedAt'}
|
||||
|
||||
def coerce(raw, original):
|
||||
if raw == '':
|
||||
return original
|
||||
if raw.strip().lower() == 'null':
|
||||
return None
|
||||
if isinstance(original, bool):
|
||||
return raw.strip().lower() in ('1', 'true', 'yes', 'y', 'on')
|
||||
if isinstance(original, int) and not isinstance(original, bool):
|
||||
try: return int(raw)
|
||||
except: return raw
|
||||
if isinstance(original, float):
|
||||
try: return float(raw)
|
||||
except: return raw
|
||||
return raw
|
||||
|
||||
def walk(node, prefix=''):
|
||||
if isinstance(node, dict):
|
||||
for k in list(node.keys()):
|
||||
path = f'{prefix}.{k}' if prefix else k
|
||||
if k in SKIP:
|
||||
continue
|
||||
v = node[k]
|
||||
if isinstance(v, dict):
|
||||
walk(v, path)
|
||||
else:
|
||||
shown = 'null' if v is None else json.dumps(v, ensure_ascii=False)
|
||||
try:
|
||||
raw = input(f' {path:28s} = {shown:30s} override: ')
|
||||
except EOFError:
|
||||
raw = ''
|
||||
node[k] = coerce(raw, v)
|
||||
|
||||
walk(doc)
|
||||
with open(dst, 'w') as f:
|
||||
json.dump(doc, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n')
|
||||
PY
|
||||
then
|
||||
echo "review: aborted (or python error) — leaving $JSON unchanged" >&2
|
||||
rm -f "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
|
||||
echo "review: produced invalid JSON, leaving $tmp for inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp" "$JSON"
|
||||
echo
|
||||
echo " ✓ updated $JSON"
|
||||
}
|
||||
|
||||
# Wraps telemetry.json as `window.__TEL = {...};` so index.html can load it
|
||||
# via a <script> tag. fetch() on file:// is blocked in CEF; script-tag isn't.
|
||||
wrap_js() {
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "telemetry.json not found — run with --collect first" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! python3 -m json.tool "$JSON" >/dev/null 2>&1; then
|
||||
echo "telemetry.json is not valid JSON — fix it before re-wrapping" >&2
|
||||
exit 1
|
||||
fi
|
||||
local tmp="$JS.tmp.$$"
|
||||
{ printf 'window.__TEL = '; cat "$JSON"; printf ';\n'; } > "$tmp"
|
||||
mv -f "$tmp" "$JS"
|
||||
echo "wrote $JS"
|
||||
}
|
||||
|
||||
# ── Argument parsing ────────────────────────────────
|
||||
DO_COLLECT=0
|
||||
DO_REVIEW=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--collect) DO_COLLECT=1 ;;
|
||||
--no-review) DO_REVIEW=0 ;;
|
||||
-h|--help) sed -n 's/^# \?//p' "$0" | head -16; exit 0 ;;
|
||||
*) echo "unknown flag: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if (( DO_COLLECT )); then
|
||||
collect
|
||||
(( DO_REVIEW )) && review
|
||||
fi
|
||||
wrap_js
|
||||
237
archived/vendor/obs-ws-mini.js
vendored
Normal file
237
archived/vendor/obs-ws-mini.js
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
// Minimal OBS WebSocket v5 client (JSON variant).
|
||||
// Just what the music daemon + loading overlay need:
|
||||
// - connect with optional auth (HMAC-SHA256 challenge)
|
||||
// - call('BroadcastCustomEvent', { eventData: { ... } })
|
||||
// - onCustom(cb) → fires for incoming CustomEvent broadcasts
|
||||
//
|
||||
// Protocol reference:
|
||||
// https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||
//
|
||||
// Exposes: window.OBSWSMini
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// Pure-JS SHA-256 (FIPS 180-4) → base64. Used when crypto.subtle is
|
||||
// unavailable — e.g. OBS's CEF browser source, where file:// URLs do
|
||||
// not grant secure-context status, so window.crypto.subtle is undefined.
|
||||
// Input is treated as a JS string and encoded UTF-8 before hashing.
|
||||
function _sha256b64Pure(str) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let c = str.charCodeAt(i);
|
||||
if (c < 0x80) bytes.push(c);
|
||||
else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
||||
else if (c < 0xd800 || c >= 0xe000) {
|
||||
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
} else {
|
||||
i++;
|
||||
c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
|
||||
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f),
|
||||
0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
}
|
||||
}
|
||||
const bitLen = bytes.length * 8;
|
||||
bytes.push(0x80);
|
||||
while (bytes.length % 64 !== 56) bytes.push(0);
|
||||
const high = Math.floor(bitLen / 0x100000000);
|
||||
const low = bitLen >>> 0;
|
||||
for (let i = 3; i >= 0; i--) bytes.push((high >>> (i * 8)) & 0xff);
|
||||
for (let i = 3; i >= 0; i--) bytes.push((low >>> (i * 8)) & 0xff);
|
||||
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
const K = [
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
||||
];
|
||||
const ROTR = (x, n) => (x >>> n) | (x << (32 - n));
|
||||
const W = new Uint32Array(64);
|
||||
for (let block = 0; block < bytes.length; block += 64) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
W[i] = ((bytes[block + i*4] << 24) |
|
||||
(bytes[block + i*4+1] << 16) |
|
||||
(bytes[block + i*4+2] << 8) |
|
||||
bytes[block + i*4+3]) >>> 0;
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = ROTR(W[i-15], 7) ^ ROTR(W[i-15], 18) ^ (W[i-15] >>> 3);
|
||||
const s1 = ROTR(W[i-2], 17) ^ ROTR(W[i-2], 19) ^ (W[i-2] >>> 10);
|
||||
W[i] = (W[i-16] + s0 + W[i-7] + s1) >>> 0;
|
||||
}
|
||||
let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
|
||||
const S0 = ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22);
|
||||
const mj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + mj) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + t1) >>> 0;
|
||||
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
|
||||
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
|
||||
}
|
||||
let bin = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
bin += String.fromCharCode((H[i]>>>24)&0xff, (H[i]>>>16)&0xff, (H[i]>>>8)&0xff, H[i]&0xff);
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
class OBSWSMini extends EventTarget {
|
||||
constructor(url, password) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.password = password || '';
|
||||
this._reqId = 0;
|
||||
this._pending = new Map();
|
||||
this.ws = null;
|
||||
this.identified = false;
|
||||
}
|
||||
|
||||
async _sha256b64(str) {
|
||||
// Prefer WebCrypto when available (HTTPS/localhost contexts).
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle && crypto.subtle.digest) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
}
|
||||
return _sha256b64Pure(str);
|
||||
}
|
||||
|
||||
async _authString(salt, challenge) {
|
||||
const secret = await this._sha256b64(this.password + salt);
|
||||
return await this._sha256b64(secret + challenge);
|
||||
}
|
||||
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let identified = false;
|
||||
let settled = false;
|
||||
const settle = (fn, val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
fn(val);
|
||||
};
|
||||
|
||||
// Hard timeout — if the server never sends Identified within 10s,
|
||||
// give up so the caller can show an error instead of hanging.
|
||||
const timeout = setTimeout(() => {
|
||||
if (!identified) {
|
||||
try { this.ws?.close(); } catch {}
|
||||
settle(reject, new Error('connect timeout (no Identified within 10s)'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => console.log('[OBSWS] socket open →', this.url);
|
||||
this.ws.onerror = () => settle(reject, new Error('ws error'));
|
||||
this.ws.onclose = (ev) => {
|
||||
this.identified = false;
|
||||
console.log(`[OBSWS] socket closed (code=${ev.code} reason='${ev.reason || ''}' clean=${ev.wasClean})`);
|
||||
if (!identified) {
|
||||
settle(reject, new Error(`closed before Identified (code=${ev.code} reason='${ev.reason || 'none'}')`));
|
||||
}
|
||||
this.dispatchEvent(new Event('close'));
|
||||
};
|
||||
|
||||
this.ws.onmessage = async (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch { return; }
|
||||
console.log('[OBSWS] ←', m.op, m.d?.eventType || m.d?.requestType || '');
|
||||
|
||||
try {
|
||||
if (m.op === 0) { // Hello
|
||||
let auth;
|
||||
if (m.d.authentication) {
|
||||
if (!this.password) {
|
||||
return settle(reject, new Error('server requires password'));
|
||||
}
|
||||
auth = await this._authString(m.d.authentication.salt, m.d.authentication.challenge);
|
||||
}
|
||||
const identifyMsg = {
|
||||
op: 1,
|
||||
d: { rpcVersion: 1, authentication: auth, eventSubscriptions: 0xFFFFFFFF },
|
||||
};
|
||||
console.log('[OBSWS] → 1 (Identify, auth-len=' + (auth?.length || 0) + ')');
|
||||
this.ws.send(JSON.stringify(identifyMsg));
|
||||
} else if (m.op === 2) { // Identified
|
||||
identified = true;
|
||||
this.identified = true;
|
||||
settle(resolve, this);
|
||||
this.dispatchEvent(new Event('identified'));
|
||||
} else if (m.op === 5) { // Event
|
||||
this.dispatchEvent(new CustomEvent('event', { detail: m.d }));
|
||||
if (m.d.eventType === 'CustomEvent') {
|
||||
this.dispatchEvent(new CustomEvent('custom', { detail: m.d.eventData || {} }));
|
||||
}
|
||||
} else if (m.op === 7) { // RequestResponse
|
||||
const p = this._pending.get(m.d.requestId);
|
||||
if (!p) return;
|
||||
this._pending.delete(m.d.requestId);
|
||||
if (m.d.requestStatus && m.d.requestStatus.result) {
|
||||
p.resolve(m.d.responseData || {});
|
||||
} else {
|
||||
p.reject(new Error(m.d.requestStatus?.comment || 'request failed'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OBSWS] message handler error:', e);
|
||||
settle(reject, new Error(`message handler error: ${e.message}`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
call(requestType, requestData) {
|
||||
if (!this.identified) return Promise.reject(new Error('not identified'));
|
||||
const requestId = `r_${++this._reqId}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
this._pending.set(requestId, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({
|
||||
op: 6,
|
||||
d: { requestType, requestId, requestData: requestData || {} },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
if (this._pending.has(requestId)) {
|
||||
this._pending.delete(requestId);
|
||||
reject(new Error('request timeout'));
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: broadcast a namespaced custom event to all OBS WS clients.
|
||||
broadcast(eventType, payload) {
|
||||
return this.call('BroadcastCustomEvent', {
|
||||
eventData: Object.assign({ _type: eventType }, payload || {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: subscribe to incoming custom events. Filtered by _type if given.
|
||||
onCustom(typeOrCb, cb) {
|
||||
const filter = typeof typeOrCb === 'string' ? typeOrCb : null;
|
||||
const fn = filter ? cb : typeOrCb;
|
||||
this.addEventListener('custom', (e) => {
|
||||
const d = e.detail || {};
|
||||
if (filter && d._type !== filter) return;
|
||||
fn(d);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
root.OBSWSMini = OBSWSMini;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user