Files
obs-config/music-box/widget.html
Jakub Zych 628ba98d3b 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.
2026-04-27 03:45:31 +02:00

315 lines
9.1 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>