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.
633 lines
20 KiB
HTML
633 lines
20 KiB
HTML
<!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>
|