544 lines
16 KiB
HTML
544 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 ──
|
|
// 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>
|