316 lines
9.2 KiB
HTML
316 lines
9.2 KiB
HTML
<!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();
|
||
}
|
||
|
||
// Dedupe by file URI, not s.index — see music-box/index.html for context.
|
||
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('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>
|