webapp: introduce Laravel app under webapp/ for scene overlays

Replaces the per-scene HTML directories (landing/, loading/, game/,
desktop/, goodbye/, music-box/, music/) with a single Laravel app
serving every overlay over HTTP. Supervisord runs `php artisan serve`
on 127.0.0.1:1118 and the OBS scene JSON now references HTTP routes
instead of file:// URLs.

Highlights:
 - public/css/hud.css consolidates the duplicated HUD chrome,
   scanlines/vignette/flicker, terminal styling, and pulse keyframes
   that were copy-pasted across all seven scenes.
 - Blade partials own hud-strip, crt-overlays, obs-ws-scripts,
   camera-frame, screen-frame; the seven scenes extend a shared
   overlay layout.
 - Artisan commands (`rig:setup`, `rig:telemetry`, `rig:loading`,
   `rig:playlist`, `rig:cmd`) replace the shell scripts that wrote
   per-rig JSON snapshots. TwitchHelix + HardwareSnapshot services
   handle the work the bash + Python helpers used to.
 - ObsWsClient + MusicCommandController kill the 250 ms cmd.js poll
   in the music daemon: POST /cmd/{skip|prev|pause|resume} opens a
   short-lived Pawl WS, authenticates, and broadcasts mpd:cmd.
 - AudioController streams files from the configured music dirs so
   CEF can load tracks under the HTTP origin (Chromium blocks
   HTTP-origin pages from loading file:// media).
 - DataController serves /data/playlist.js (with ETag mtime cache)
   and /cover.jpg (no-store) so the existing overlays' window.__PLAYLIST
   and cover.jpg cache-bust pattern keeps working.

scripts/obs-webapp.supervisord.conf is the supervisord unit; install
to /etc/supervisor.d/obs-webapp.conf.
scripts/rewrite-scene-urls.py is a one-shot tool that rewrites
basic/scenes/Default_Stream_HUD.json from file:// to HTTP URLs.
basic/scenes/Default_Stream_HUD.json.pre-webapp is the rollback
artifact (made with OBS closed; full pre-migration state).

The seven old scene directories, vendor/, and the bash scripts are
still on disk pending visual verification; the next commit will
prune them.
This commit is contained in:
Jakub Zych
2026-05-21 12:47:10 +02:00
parent c8f9c11c93
commit 059f069ef4
85 changed files with 19180 additions and 49 deletions

View File

@@ -0,0 +1,195 @@
@extends('layouts.overlay')
@section('title', 'MUSIC BOX')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto auto auto;
gap: 22px;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
}
.terminal {
width: 64vw;
max-width: 1480px;
height: 60vh;
max-height: 760px;
min-height: 420px;
font-size: 24px;
}
</style>
@endsection
@push('data-scripts')
<script>window.__TRACK_COUNT = @json($trackCount);</script>
<script src="{{ asset('data/playlist.js') }}"></script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'onair',
'label' => 'ON AIR',
'idText' => 'OPHI-118 // MUSIC',
'signalGlyph' => '▮▮▮▮▯',
'signalProfile' => 'strong',
])
<main class="stage">
<div class="terminal">
<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 to daemon…</span><span class="np-time">[--:-- / --:--]</span></div></div></div>
</div>
</main>
<section class="status-strip">
<span class="label">CHAT</span>
<span class="cmd-group">
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
</span>
</section>
<section class="banner banner--green">
<div class="label"> TRANSMISSION OPEN </div>
<div class="title">MUSIC&nbsp;BOX</div>
<div class="sub"> STAY TUNED </div>
</section>
<footer class="foot">
<span>CH 118.0 MHz</span>
<span id="rig"> LOADING PLAYLIST </span>
<span class="onair"> ON AIR</span>
</footer>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
const playlistData = window.__PLAYLIST || { tracks: [] };
const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0);
document.getElementById('rig').textContent =
trackCount > 0 ? `LIBRARY :: ${trackCount} TRACKS` : '— EMPTY LIBRARY —';
const sleep = ms => new Promise(r => setTimeout(r, ms));
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 PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200;
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();
}
async function typeCommand(text) {
const div = document.createElement('div');
div.className = 'term-line';
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = PROMPT;
div.appendChild(p);
const body = document.createElement('span');
body.className = 'term-out';
div.appendChild(body);
term.insertBefore(div, pinnedBlock);
for (const c of text) {
body.textContent += c;
await sleep(38);
}
await sleep(280);
}
function fmtClock(s) {
if (!isFinite(s) || s < 0) return '--:--';
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
}
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 = '[--:-- / --:--]';
}
function startMusic() {
OBSWSBootstrap.onState(applyState, {
onClose: () => {
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
setOffline('daemon disconnected');
},
});
}
(async () => {
await sleep(450);
await typeCommand('music --boot --shuffle');
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
if (trackCount > 0) {
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
} else {
logBeforePins('[audio] library manifest empty', 'term-dim');
}
await sleep(180);
logBeforePins('[audio] queue: shuffle on', 'term-out');
await sleep(180);
logBeforePins('[audio] standby for transmission ✓', 'term-out');
await sleep(280);
startMusic();
})();
</script>
@endsection

View File

@@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Box cover</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
html, body {
background: var(--term-bg);
color: var(--term-fg);
font-family: var(--mono);
}
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; }
.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); }
.scanlines { opacity: 0.32; 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); }
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>
@include('partials.obs-ws-scripts')
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
<script>
'use strict';
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');
// Cover served by Laravel — same origin as the page, so a relative
// /cover.jpg works without CORS concerns. Cache-busted with ?v=<hash>.
const COVER_URL = '/cover.jpg';
let lastHash = undefined;
function showArt(_path, hash) {
if (hash === lastHash) return;
lastHash = hash;
if (!hash) {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
return;
}
coverImg.onload = () => {
coverImg.classList.add('on');
placeholder.classList.add('off');
};
coverImg.onerror = () => {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
};
coverImg.src = `${COVER_URL}?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;
}
OBSWSBootstrap.onState(applyState, {
onClose: () => setOffline('disconnected'),
});
</script>
</body>
</html>

View File

@@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Daemon</title>
<style>
html, body { margin: 0; padding: 0; background: #0a0e0a; overflow: hidden; color: #5fdc62;
font: 14px/1.45 'DejaVu Sans Mono', 'Consolas', monospace; }
#status { padding: 12px 14px; }
#status h1 { font-size: 11px; letter-spacing: 0.25em; color: #2c8d2f; margin: 0 0 8px; font-weight: 700; }
#status .row { display: flex; gap: 10px; margin: 2px 0; }
#status .k { color: #2c8d2f; min-width: 70px; }
#status .v { color: #97f99a; word-break: break-all; }
#status .err { color: #ff6f5a; }
#status .ok { color: #97f99a; }
</style>
</head>
<body>
<div id="status">
<h1>OPHI MUSIC DAEMON</h1>
<div class="row"><span class="k">ws</span><span class="v" id="s-ws">init…</span></div>
<div class="row"><span class="k">queue</span><span class="v" id="s-queue"></span></div>
<div class="row"><span class="k">now</span><span class="v" id="s-now"></span></div>
<div class="row"><span class="k">audio</span><span class="v" id="s-audio"></span></div>
<div class="row"><span class="k">last cmd</span><span class="v" id="s-cmd"></span></div>
</div>
<audio id="player" preload="auto"></audio>
@include('partials.obs-ws-scripts')
<script src="{{ asset('data/playlist.js') }}"></script>
<script>
'use strict';
const data = window.__PLAYLIST || { tracks: [] };
const allTracks = data.tracks || [];
const audio = document.getElementById('player');
audio.volume = 1.0;
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const queue = shuffle([...allTracks]);
let qIdx = 0;
let stallTimer = null;
let _watchT = 0, _watchAt = Date.now();
const $ws = document.getElementById('s-ws');
const $q = document.getElementById('s-queue');
const $n = document.getElementById('s-now');
const $a = document.getElementById('s-audio');
const $c = document.getElementById('s-cmd');
function setWs(text, cls = '') { $ws.textContent = text; $ws.className = 'v ' + cls; }
function refreshStatus() {
$q.textContent = window.__PLAYLIST
? `${allTracks.length} tracks (playlist.js loaded)`
: 'playlist.js NOT loaded';
const t = allTracks.length ? `#${qIdx + 1}/${allTracks.length} — ${queue[qIdx]?.title || '?'}` : '—';
$n.textContent = t;
let aState = 'idle';
if (audio.paused) aState = 'paused';
else if (audio.ended) aState = 'ended';
else if (audio.src) aState = `playing (${audio.currentTime.toFixed(0)}s / ${(audio.duration||0).toFixed(0)}s)`;
$a.textContent = aState;
}
setInterval(refreshStatus, 500);
refreshStatus();
function clearStall() { clearTimeout(stallTimer); stallTimer = null; }
let obs = null;
async function connectOBS() {
if (!window.__OBSWS) {
setWs('public/js/obs-config.js MISSING — run `php artisan rig:setup`', 'err');
return;
}
try {
setWs(`connecting → ${window.__OBSWS.url}`);
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
setWs('identified ✓', 'ok');
obs.addEventListener('close', () => {
setWs('closed — retrying in 2s', 'err');
setTimeout(connectOBS, 2000);
});
obs.onCustom('mpd:cmd', (d) => handleCommand(d));
broadcastState();
} catch (e) {
setWs(`connect failed (${e.message}) — retrying`, 'err');
setTimeout(connectOBS, 2500);
}
}
connectOBS();
function broadcastState() {
if (!obs || !obs.identified) return;
const t = queue[qIdx];
obs.broadcast('mpd:state', {
index: qIdx,
total: queue.length,
title: t?.title || null,
file: t?.file || null,
currentTime: audio.currentTime || 0,
duration: audio.duration || t?.durationSec || 0,
paused: audio.paused,
}).catch(() => {});
}
function playCurrent() {
if (queue.length === 0) return;
qIdx = ((qIdx % queue.length) + queue.length) % queue.length;
const t = queue[qIdx];
clearStall();
audio.src = t.file;
stallTimer = setTimeout(() => {
console.warn('[music] start timeout, skipping', t.title);
advance(+1);
}, 15000);
audio.play().catch(() => {
clearStall();
console.warn('[music] play() rejected, skipping', t.title);
advance(+1);
});
broadcastState();
}
function advance(direction) {
clearStall();
if (queue.length === 0) return;
qIdx = (qIdx + direction + queue.length) % queue.length;
playCurrent();
}
audio.addEventListener('ended', () => advance(+1));
audio.addEventListener('error', () => { console.warn('[music] decode error'); advance(+1); });
audio.addEventListener('playing', clearStall);
setInterval(() => {
if (audio.paused || audio.ended || !audio.src) {
_watchT = audio.currentTime;
_watchAt = Date.now();
return;
}
if (Math.abs(audio.currentTime - _watchT) >= 0.05) {
_watchT = audio.currentTime;
_watchAt = Date.now();
} else if (Date.now() - _watchAt > 8000) {
console.warn('[music] mid-track stall, skipping');
_watchT = 0; _watchAt = Date.now();
advance(+1);
}
}, 1000);
setInterval(broadcastState, 1000);
// Only command source now: OBS WS `mpd:cmd`. The Twitch bot and the
// Laravel `POST /cmd/{type}` route both broadcast through this same event.
function handleCommand(cmd) {
const type = (cmd?.type || '').toLowerCase();
$c.textContent = `${type} @ ${new Date().toLocaleTimeString()}`;
switch (type) {
case 'skip': advance(+1); break;
case 'prev': advance(-1); break;
case 'pause': audio.pause(); broadcastState(); break;
case 'resume': audio.play().catch(() => {}); broadcastState(); break;
default:
console.warn('[music] unknown command:', type);
}
}
if (queue.length > 0) {
playCurrent();
} else {
console.warn('[music] no tracks — run `php artisan rig:playlist`');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,517 @@
@extends('layouts.overlay')
@section('title', 'NC-MUSIC-BOX')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 22px;
font-family: var(--mono);
}
.tui {
display: grid;
grid-template-columns: 1fr 36ch;
gap: 24px;
min-height: 0;
}
.pane {
position: relative;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 4px;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow:
0 0 60px rgba(80, 220, 100, 0.10),
inset 0 0 90px rgba(0, 30, 0, 0.65);
}
.pane::after {
content: '';
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.28) 3px,
rgba(0,0,0,0.28) 4px
);
opacity: 0.55;
mix-blend-mode: multiply;
}
.pane > .pane-tab {
position: absolute;
top: -14px; left: 28px;
padding: 2px 14px;
background: var(--bg);
color: var(--term-fg-bright);
font-size: 16px;
letter-spacing: 0.32em;
text-shadow: 0 0 6px var(--term-glow);
z-index: 1;
}
.pane.terminal {
padding: 28px 36px;
font-size: 24px;
line-height: 1.55;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
}
.np-pane {
padding: 24px 22px 20px;
font-size: 18px;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
display: grid;
grid-template-rows: auto auto auto auto 1fr auto auto;
gap: 18px;
}
.np-art {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(0, 30, 0, 0.45);
border: 1px solid var(--term-edge-soft);
overflow: hidden;
}
.np-art img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 320ms ease;
}
.np-art img.on { opacity: 1; }
.np-art .placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 10px; color: var(--term-fg-dim); text-align: center;
transition: opacity 320ms ease;
}
.np-art .placeholder.off { opacity: 0; }
.np-art .placeholder svg { opacity: 0.5; }
.np-art .placeholder .ph-text {
font-size: 14px;
letter-spacing: 0.42em;
color: rgba(95, 220, 98, 0.65);
}
.np-meta {
display: grid; grid-template-columns: 6.5em 1fr;
row-gap: 6px; column-gap: 12px;
font-size: 17px; align-content: start;
}
.np-meta .key { color: var(--term-fg-dim); letter-spacing: 0.18em; }
.np-meta .val {
color: var(--term-fg-bright);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
min-width: 0;
}
.np-meta .val.empty { color: var(--term-fg-dim); }
.np-stats {
display: flex; flex-direction: column; gap: 8px;
padding-top: 6px;
border-top: 1px dashed var(--term-edge-soft);
}
.np-stats .stats-label {
font-size: 14px; letter-spacing: 0.32em;
color: var(--term-fg-dim);
text-shadow: 0 0 4px var(--term-glow);
}
.np-stats .stats-grid {
display: grid; grid-template-columns: 6.5em 1fr;
row-gap: 4px; column-gap: 12px;
font-size: 16px;
}
.np-stats .stats-grid .key { color: var(--term-fg-dim); letter-spacing: 0.16em; }
.np-stats .stats-grid .val {
color: var(--term-fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
.np-stats .stats-grid .val.lossless {
color: var(--term-fg-bright);
text-shadow: 0 0 8px var(--term-glow);
}
.np-spectrum { min-height: 64px; }
.np-progress { display: grid; grid-template-rows: auto auto; gap: 6px; }
.np-progress .bar {
position: relative; height: 6px;
background: rgba(95, 220, 98, 0.14);
border: 1px solid var(--term-edge-soft);
}
.np-progress .bar .fill {
position: absolute; inset: 0 auto 0 0;
width: 0%;
background: var(--term-fg);
box-shadow: 0 0 8px var(--term-glow);
transition: width 350ms linear;
}
.np-progress .time {
font-size: 15px; color: var(--term-fg-dim);
letter-spacing: 0.10em;
display: flex; justify-content: space-between;
}
.np-progress .time .now { color: var(--term-fg-bright); }
</style>
@endsection
@push('data-scripts')
<script>window.__TRACK_COUNT = @json($trackCount);</script>
<script src="{{ asset('data/playlist.js') }}"></script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'onair',
'label' => 'ON AIR',
'idText' => 'OPHI-118 // MUSIC',
'signalGlyph' => '▮▮▮▮▯',
'signalProfile' => 'strong',
])
<main class="tui">
<section class="pane terminal">
<span class="pane-tab"> TERMINAL </span>
<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 to daemon…</span><span class="np-time">[--:-- / --:--]</span></div></div></div>
</section>
<aside class="pane np-pane">
<span class="pane-tab"> NOW PLAYING </span>
<div class="np-art">
<img id="cover" alt="">
<div class="placeholder" id="placeholder">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="46" height="46" rx="2" stroke="#5fdc62" stroke-width="2"/>
<circle cx="28" cy="28" r="12" stroke="#5fdc62" stroke-width="2"/>
<circle cx="28" cy="28" r="3" fill="#5fdc62"/>
</svg>
<span class="ph-text"> NO ART </span>
</div>
</div>
<div class="np-meta">
<span class="key">ARTIST</span><span class="val empty" id="metaArtist"></span>
<span class="key">TITLE</span> <span class="val empty" id="metaTitle"></span>
<span class="key">ALBUM</span> <span class="val empty" id="metaAlbum"></span>
<span class="key">YEAR</span> <span class="val empty" id="metaYear"></span>
<span class="key">GENRE</span> <span class="val empty" id="metaGenre"></span>
</div>
<div class="np-stats">
<div class="stats-label"> FILE </div>
<div class="stats-grid">
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat"></span>
<span class="key">QUALITY</span><span class="val empty" id="fileQuality"></span>
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate"></span>
</div>
</div>
<div class="np-stats">
<div class="stats-label"> LIBRARY </div>
<div class="stats-grid">
<span class="key">ARTISTS</span> <span class="val empty" id="statArtists"></span>
<span class="key">ALBUMS</span> <span class="val empty" id="statAlbums"></span>
<span class="key">SONGS</span> <span class="val empty" id="statSongs"></span>
<span class="key">PLAYTIME</span><span class="val empty" id="statPlaytime"></span>
<span class="key">QUEUE</span> <span class="val empty" id="statQueue"></span>
</div>
</div>
<div aria-hidden="true"></div>
<div class="np-spectrum" id="spectrumSlot"></div>
<div class="np-progress">
<div class="bar"><div class="fill" id="progFill"></div></div>
<div class="time">
<span class="now" id="progElapsed">00:00</span>
<span id="progTotal">00:00</span>
</div>
</div>
</aside>
</main>
<section class="status-strip">
<span class="label">CHAT</span>
<span class="cmd-group">
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
</span>
</section>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
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 playlistData = window.__PLAYLIST || { tracks: [] };
const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0);
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 nextRows = nextSlots.map(slot => slot.closest('.next-line'));
const PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200;
const MODE_KEYS = ['repeat', 'random', 'single'];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
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();
}
async function typeCommand(text) {
const div = document.createElement('div');
div.className = 'term-line';
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = PROMPT;
div.appendChild(p);
const body = document.createElement('span');
body.className = 'term-out';
div.appendChild(body);
term.insertBefore(div, pinnedBlock);
for (const c of text) {
body.textContent += c;
await sleep(38);
}
await sleep(280);
return body;
}
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatBootCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--boot'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => { if (p[key]) parts.push(`--${key}`); });
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
function nextPreviewConfig(playback) {
const p = normalizePlayback(playback);
if (p.single && p.repeat) return { count: 1, empty: '— repeat single armed —' };
if (p.single) return { count: 0, empty: '— single mode —' };
if (p.random) return { count: 1, empty: '— shuffle active —' };
return { count: 3, empty: '— end of queue —' };
}
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
}
let modeLogChain = Promise.resolve();
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) { lastPlaybackModes = current; return; }
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
const coverImg = document.getElementById('cover');
const placeholder = document.getElementById('placeholder');
const metaArtist = document.getElementById('metaArtist');
const metaTitle = document.getElementById('metaTitle');
const metaAlbum = document.getElementById('metaAlbum');
const metaYear = document.getElementById('metaYear');
const metaGenre = document.getElementById('metaGenre');
const progFill = document.getElementById('progFill');
const progElapsed = document.getElementById('progElapsed');
const progTotal = document.getElementById('progTotal');
const statArtists = document.getElementById('statArtists');
const statAlbums = document.getElementById('statAlbums');
const statSongs = document.getElementById('statSongs');
const statPlaytime = document.getElementById('statPlaytime');
const statQueue = document.getElementById('statQueue');
const fileFormat = document.getElementById('fileFormat');
const fileQuality = document.getElementById('fileQuality');
const fileBitrate = document.getElementById('fileBitrate');
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
function fmtSampleRate(hz) {
if (!hz || !isFinite(hz) || hz <= 0) return null;
const khz = hz / 1000;
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
return `${s} kHz`;
}
function fmtQuality(audio) {
const sr = fmtSampleRate(audio.samplerate);
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
? `${audio.bits}-bit` : null;
if (sr && bits) return `${sr} · ${bits}`;
return sr || bits || null;
}
const fmtCount = n => (typeof n === 'number' && isFinite(n))
? n.toLocaleString('en-US') : null;
function setMeta(el, value) {
if (value && String(value).trim()) {
el.textContent = value;
el.classList.remove('empty');
} else {
el.textContent = '—';
el.classList.add('empty');
}
}
let lastCoverHash = undefined;
function showArt(_path, hash) {
if (hash === lastCoverHash) return;
lastCoverHash = hash;
if (!hash) {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
return;
}
coverImg.onload = () => { coverImg.classList.add('on'); placeholder.classList.add('off'); };
coverImg.onerror = () => { coverImg.classList.remove('on'); placeholder.classList.remove('off'); };
coverImg.src = `/cover.jpg?v=${hash}`;
}
let lastTrackFile = null;
function renderState(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 : [];
const preview = nextPreviewConfig(s.playback);
nextSlots.forEach((slot, i) => {
const v = i < preview.count ? titles[i] : null;
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
nextRows[i].style.display = '';
} else {
if (i === 0) {
slot.textContent = preview.empty;
slot.classList.add('empty');
nextRows[i].style.display = '';
} else {
slot.textContent = '';
slot.classList.add('empty');
nextRows[i].style.display = 'none';
}
}
});
showArt(s.coverPath, s.coverHash);
setMeta(metaArtist, s.artist);
setMeta(metaTitle, s.trackTitle);
setMeta(metaAlbum, s.album);
setMeta(metaYear, s.year);
setMeta(metaGenre, s.genre);
const dur = Number(s.duration) || 0;
const cur = Number(s.currentTime) || 0;
const pct = dur > 0 ? Math.max(0, Math.min(100, (cur / dur) * 100)) : 0;
progFill.style.width = `${pct}%`;
progElapsed.textContent = fmtClock(cur);
progTotal.textContent = fmtClock(dur);
const audio = s.audio || {};
setMeta(fileFormat, audio.format);
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
setMeta(fileQuality, fmtQuality(audio));
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
const stats = s.stats;
if (stats) {
setMeta(statArtists, fmtCount(stats.artists));
setMeta(statAlbums, fmtCount(stats.albums));
setMeta(statSongs, fmtCount(stats.songs));
setMeta(statPlaytime, stats.dbPlaytime);
}
const total = Number(s.total) || 0;
const idx = Number.isInteger(s.index) ? s.index : -1;
setMeta(statQueue, total > 0 && idx >= 0 ? `${idx + 1} / ${total}` : null);
pruneTerminal();
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatBootCommand(s.playback));
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
if (trackCount > 0) {
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
} else {
logBeforePins('[audio] library manifest empty', 'term-dim');
}
await sleep(180);
logBeforePins('[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderState(pendingState);
}
function applyState(s) {
pendingState = s;
if (!bootStarted) { bootFromState(s); return; }
if (!bootFinished) return;
renderState(s);
trackModeChanges(s.playback);
}
function setOffline(msg) {
npTitle.textContent = msg;
npTime.textContent = '[--:-- / --:--]';
}
(async () => {
await sleep(450);
logBeforePins('[audio] waiting for mpd state', 'term-dim', null);
await sleep(280);
OBSWSBootstrap.onState(applyState, {
onClose: () => {
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
setOffline('daemon disconnected');
},
});
})();
</script>
@endsection

View File

@@ -0,0 +1,196 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Box widget</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
/* Widget is authored at 549×880 (bounds_type 0, no stretch). */
html, body {
background: var(--term-bg);
color: var(--term-fg);
font-family: var(--mono);
}
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);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.terminal::after { display: none; }
.scanlines { opacity: 0.45; }
.np-line { display: flex; gap: 10px; align-items: baseline; }
.np-line .np-arrow { flex: 0 0 auto; margin-right: 0; }
.np-line .np-title {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.np-line .np-time { flex: 0 0 auto; font-size: 14px; margin-left: 0; }
.next-line { display: flex; gap: 8px; align-items: baseline; font-size: 14px; }
.next-line .next-arrow { flex: 0 0 auto; }
.next-line .next-label { flex: 0 0 auto; }
.next-line .next-title {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.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); }
.pin-spacer { height: 10px; }
</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">
<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>
@include('partials.obs-ws-scripts')
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
<script>
'use strict';
const pad = n => String(n).padStart(2, '0');
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 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 = '[--:-- / --:--]';
}
(async () => {
await new Promise(r => setTimeout(r, 200));
logBeforePins('[audio] subscribing to mpd:state', 'term-out');
await new Promise(r => setTimeout(r, 180));
logBeforePins('[audio] queue: shuffle on', 'term-out');
await new Promise(r => setTimeout(r, 180));
logBeforePins('[audio] standby ✓', 'term-out');
await new Promise(r => setTimeout(r, 220));
OBSWSBootstrap.onState(applyState, {
onClose: () => { logBeforePins('daemon disconnected', 'term-dim'); setOffline('disconnected'); },
});
})();
</script>
</body>
</html>