Codex. Cover support, updates for Music Box

This commit is contained in:
Jakub Zych
2026-04-27 21:10:13 +02:00
parent e961d264c0
commit 4c818e327b
10 changed files with 2248 additions and 129 deletions

295
music-box/cover.html Normal file
View 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>

804
music-box/nc-music-box.html Normal file
View File

@@ -0,0 +1,804 @@
<!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 | 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 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); }
/* 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">─ 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 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(' ');
}
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 (lastTrackIndex !== null && s.index !== lastTrackIndex && s.title) {
logBeforePins(`[audio] now: ${s.title}`);
}
lastTrackIndex = s.index;
// Terminal pinned block
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');
}
});
// 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);
// 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 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 ──
let lastTrackIndex = 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>