New full-screen scene (Music Box, Ctrl+M) for music-only foreground use. Built on the same terminal pattern as Goodbye/Loading: typed boot sequence (music --boot --shuffle, [audio] subscribing, library count, queue: shuffle on, standby ✓), scrolling [audio] now: <title> history on track changes, static-HTML pinned block at the bottom (3-up next/then/then + arrow-pulse now-playing line). Below the terminal, a dedicated "CHAT !skip · !queue · !info" status strip — kept outside the scrolling area on purpose so the command help stays visible regardless of terminal contents. Desktop scene now mirrors Game's right-sidebar layout: - Camera moved into top-right at the Game-scene transform (1937,98 × 0.286), always visible (vs. Game which toggles per-session). - New Music Box widget (music-box/widget.html) directly below camera, same width (549 px) so the two pair as one column. 880 px tall to fill the right sidebar down to the canvas bottom. Authored at native pixel size with bounds_type 0 (no stretch) — pinned 3-up next + inline arrow/title/time + chat help; same scrolling [audio] history pattern as the full-screen Music Box. - HUD swapped from the shared Game HUD to a forked desktop/index.html: same top-strip + camera-frame OBS-sync, but no game-name slot (status reads "DESKTOP"), no manifest dependence, no status-bar music widget (lives in the dedicated sidebar source now). Pinned blocks in both Music Box overlays are static HTML so the structure renders even if a script throws — JS only updates text content.
539 lines
16 KiB
HTML
539 lines
16 KiB
HTML
<!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 ──
|
|
let lastTrackIndex = null;
|
|
function applyState(s) {
|
|
if (lastTrackIndex !== null && s.index !== lastTrackIndex && s.title) {
|
|
logBeforePins(`[audio] now: ${s.title}`);
|
|
}
|
|
lastTrackIndex = s.index;
|
|
|
|
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>
|