Music Box scene + Desktop scene right-sidebar rework

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.
This commit is contained in:
Jakub Zych
2026-04-27 03:45:31 +02:00
parent 131df13389
commit 628ba98d3b
4 changed files with 1586 additions and 7 deletions

314
music-box/widget.html Normal file
View File

@@ -0,0 +1,314 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Music Box widget</title>
<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 == widget panel == terminal. Authored at native 549×880; the OBS
browser_source must match (bounds_type 0, no stretch). */
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;
}
.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; }
.terminal {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 22px 16px;
font-size: 16px;
line-height: 1.5;
color: var(--term-fg);
}
#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); }
.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.45;
mix-blend-mode: multiply;
}
/* ───────── pinned bottom block ───────── */
.pin-spacer { height: 10px; }
.np-line {
display: flex;
gap: 10px;
align-items: baseline;
}
.np-line .np-arrow {
color: var(--term-fg-bright);
flex: 0 0 auto;
animation: np-pulse 1.05s ease-in-out infinite alternate;
}
.np-line .np-title {
color: var(--term-fg-bright);
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.np-line .np-time {
color: var(--term-fg-dim);
flex: 0 0 auto;
font-size: 14px;
}
@keyframes np-pulse {
0% { opacity: 0.45; }
100% { opacity: 1.00; }
}
.next-line {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 14px;
}
.next-line .next-arrow { color: var(--term-fg-dim); flex: 0 0 auto; }
.next-line .next-label { color: var(--term-fg-dim); flex: 0 0 auto; }
.next-line .next-title {
color: var(--term-fg);
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.next-line .next-title.empty { color: var(--term-fg-dim); }
.chat-line {
font-size: 13px;
color: var(--term-fg-dim);
letter-spacing: 0.06em;
border-top: 1px solid rgba(95, 220, 98, 0.18);
padding-top: 8px;
margin-top: 6px;
}
.chat-line .cmd { color: var(--term-fg-bright); }
</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">NOW PLAYING</span>
<span class="right">CH 118.0 // MUSIC</span>
</div>
<div class="terminal">
<!-- Static pinned block — JS only updates text content. If JS fails,
the structure is still visible (better than an empty panel). -->
<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…</span><span class="np-time">[--:-- / --:--]</span>
</div>
<div class="term-line chat-line">
chat: <span class="cmd">!skip</span> · <span class="cmd">!queue</span> · <span class="cmd">!info</span>
</div>
</div>
</div>
</div>
<div class="scanlines"></div>
<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))}`;
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 MAX_TERM_LINES = 60;
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();
}
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('config missing', 'term-dim');
return;
}
try {
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
obs.addEventListener('close', () => {
logBeforePins('daemon disconnected', 'term-dim');
setOffline('disconnected');
setTimeout(connectMusic, 2000);
});
obs.onCustom('mpd:state', applyState);
} catch (e) {
logBeforePins('daemon unreachable', 'term-dim');
setTimeout(connectMusic, 2500);
}
}
(async () => {
await sleep(200);
logBeforePins('[audio] subscribing to mpd:state', 'term-out');
await sleep(180);
logBeforePins('[audio] queue: shuffle on', 'term-out');
await sleep(180);
logBeforePins('[audio] standby ✓', 'term-out');
await sleep(220);
connectMusic();
})();
</script>
</body>
</html>