Files
obs-config/loading/index.html
Jakub Zych 0a73f7e254 Music daemon + cross-source sync via OBS WebSocket
Music persistence across scene switches by extracting playback into a
separate browser source (music/index.html) — single source instance
shared across scenes via OBS's "Add Existing", so it doesn't restart on
scene change.

Loading and Game overlays subscribe to mpd:state CustomEvents over OBS
WebSocket; the daemon broadcasts at 1 Hz plus on every track change.
File-based command pipeline lets bash control playback without a
WebSocket client of its own:

- vendor/obs-ws-mini.js — minimal WS v5 client with HMAC-SHA256 auth,
  hard timeout, close-rejection, and a pure-JS SHA-256 fallback for
  CEF file:// origins where crypto.subtle is unavailable.
- setup.sh — generates vendor/obs-config.js from the OBS plugin config;
  checks the live socket instead of the (lagging) config file.
- music/index.html — invisible audio worker. Plays through a shuffled
  queue with hardened error/stall recovery, polls loading/cmd.js for
  bash-issued commands, broadcasts mpd:state via WS. Includes a
  diagnostic status block (visible if the source's eye-icon is on).
- loading/index.html — strips local audio playback; subscribes to
  mpd:state to drive the now-playing terminal line live. Removes the
  redundant trailing prompt; terminal lines anchor at the bottom.
- loading/playlist.sh — rebuilt as subcommand API:
    sync (default), skip, prev, pause, resume, status
  control commands write loading/cmd.js (id, type, ts), which the
  daemon polls every 250ms.
- loading/convert.sh — strips video tracks from .webm music downloads
  to audio-only m4a (~95% smaller, ~20× faster decode); fixes the
  CPU pegging from CEF decoding 1080p VP9 video for music playback.
- .gitignore — track manifests, ignore generated wrappers + secrets +
  the audio cache.
2026-04-26 02:33:50 +02:00

547 lines
16 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.30);
--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 .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
margin-right: 12px;
vertical-align: middle;
animation: rec-blink 1.6s ease-in-out infinite;
}
@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: 64vw;
max-width: 1500px;
height: 100%;
min-height: 380px;
position: relative;
overflow: hidden;
background: var(--term-bg);
border: 1px solid var(--term-edge);
border-radius: 6px;
padding: 32px 40px;
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 ───────── */
.countdown { text-align: center; }
.countdown .label {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 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;
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;
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;
}
.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>
<!-- Manifest data written by loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
<script src="loading.js"></script>
<!-- Playlist manifest written by 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 lines = [
{ kind: 'cmd', text: 'loadproject --manifest' },
{ kind: 'out', text: 'initializing transmission envelope...' },
{ 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);
}
// ── 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 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))}`;
}
// Connect to OBS WebSocket and listen for daemon broadcasts.
let lastTrackIndex = null;
async function connectMusic() {
if (!window.__OBSWS) {
logBeforeNp('[audio] vendor/obs-config.js missing — run 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) => {
if (!npLine) ensureNowPlayingLine();
// Detect track change (don't log on first state, just initialize).
if (lastTrackIndex !== null && s.index !== lastTrackIndex) {
logBeforeNp(`[audio] next: ${s.title || '?'}`);
}
lastTrackIndex = s.index;
npLine.querySelector('.np-title').textContent = s.title || '—';
npLine.querySelector('.np-time').textContent =
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
});
} catch (e) {
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
setTimeout(connectMusic, 2500);
}
}
async function startMusic() {
await sleep(500);
await typeCommand('mpd --queue ~/playlists/stream-mix');
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued — run loading/playlist.sh', 'term-dim');
return;
}
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', '[audio] connecting to daemon on ws://localhost:4455', 'term-out');
await sleep(180);
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
ensureNowPlayingLine();
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>