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>