296 lines
8.2 KiB
HTML
296 lines
8.2 KiB
HTML
<!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>
|