881 lines
29 KiB
HTML
881 lines
29 KiB
HTML
<!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 | FILE | 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 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); }
|
||
|
||
/* Lossless badge — bright green for FORMAT when the codec is FLAC/ALAC/WAV
|
||
so the "high quality" cue reads at a glance. Lossy formats (MP3/Opus/AAC)
|
||
stay in the regular term-fg color. */
|
||
.np-stats .stats-grid .val.lossless {
|
||
color: var(--term-fg-bright);
|
||
text-shadow: 0 0 8px var(--term-glow);
|
||
}
|
||
|
||
/* 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">─ FILE ─</div>
|
||
<div class="stats-grid">
|
||
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat">—</span>
|
||
<span class="key">QUALITY</span><span class="val empty" id="fileQuality">—</span>
|
||
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate">—</span>
|
||
</div>
|
||
</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 nextRows = nextSlots.map(slot => slot.closest('.next-line'));
|
||
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(' ');
|
||
}
|
||
|
||
function nextPreviewConfig(playback) {
|
||
const p = normalizePlayback(playback);
|
||
if (p.single && p.repeat) {
|
||
return { count: 1, empty: '— repeat single armed —' };
|
||
}
|
||
if (p.single) {
|
||
return { count: 0, empty: '— single mode —' };
|
||
}
|
||
if (p.random) {
|
||
return { count: 1, empty: '— shuffle active —' };
|
||
}
|
||
return { count: 3, empty: '— end of queue —' };
|
||
}
|
||
|
||
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 : [];
|
||
const preview = nextPreviewConfig(s.playback);
|
||
nextSlots.forEach((slot, i) => {
|
||
const v = i < preview.count ? titles[i] : null;
|
||
if (v) {
|
||
slot.textContent = v;
|
||
slot.classList.remove('empty');
|
||
nextRows[i].style.display = '';
|
||
} else {
|
||
if (i === 0) {
|
||
slot.textContent = preview.empty;
|
||
slot.classList.add('empty');
|
||
nextRows[i].style.display = '';
|
||
} else {
|
||
slot.textContent = '';
|
||
slot.classList.add('empty');
|
||
nextRows[i].style.display = 'none';
|
||
}
|
||
}
|
||
});
|
||
|
||
// 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);
|
||
|
||
// File / audio info — codec from extension, sample rate + bit depth +
|
||
// bitrate from MPD `status.audio` and `status.bitrate`. FLAC and friends
|
||
// get the bright "lossless" treatment on FORMAT to advertise quality.
|
||
const audio = s.audio || {};
|
||
setMeta(fileFormat, audio.format);
|
||
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
|
||
setMeta(fileQuality, fmtQuality(audio));
|
||
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
|
||
|
||
// 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 fileFormat = document.getElementById('fileFormat');
|
||
const fileQuality = document.getElementById('fileQuality');
|
||
const fileBitrate = document.getElementById('fileBitrate');
|
||
|
||
// Codecs whose bitstream is mathematically lossless — drives the bright
|
||
// FORMAT highlight in the FILE block.
|
||
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
|
||
|
||
// "44100" → "44.1 kHz", "96000" → "96 kHz". Trim the decimal when the rate
|
||
// is a clean multiple of 1000 so common rates render as "44.1/48/96 kHz".
|
||
function fmtSampleRate(hz) {
|
||
if (!hz || !isFinite(hz) || hz <= 0) return null;
|
||
const khz = hz / 1000;
|
||
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
|
||
return `${s} kHz`;
|
||
}
|
||
|
||
// Quality cell merges sample rate + bit depth: "96 kHz · 24-bit". Either
|
||
// half can be missing (DSD streams report bits='dsd64' which we drop, MP3s
|
||
// sometimes lack a clean rate at start of decode).
|
||
function fmtQuality(audio) {
|
||
const sr = fmtSampleRate(audio.samplerate);
|
||
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
|
||
? `${audio.bits}-bit` : null;
|
||
if (sr && bits) return `${sr} · ${bits}`;
|
||
return sr || bits || null;
|
||
}
|
||
|
||
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>
|