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:
148
webapp/resources/views/overlays/desktop.blade.php
Normal file
148
webapp/resources/views/overlays/desktop.blade.php
Normal file
@@ -0,0 +1,148 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'DESKTOP')
|
||||
|
||||
@section('body-class', 'camera-hud-body')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
:root {
|
||||
--cam-x: 1937px; --cam-y: 98px; --cam-w: 549px; --cam-h: 309px; --cam-pad: 10px;
|
||||
--scr-x: 10px; --scr-y: 14px; --scr-w: 1976px; --scr-h: 1209px; --scr-pad: 0px;
|
||||
}
|
||||
#signal { transform: translateY(-0.10em); }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('crt')
|
||||
{{-- transparent overlay — no CRT overlays --}}
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@include('partials.screen-frame', ['id' => 'scr', 'label' => 'SCR 01'])
|
||||
@include('partials.camera-frame', ['id' => 'cam', 'label' => 'CAM 01'])
|
||||
|
||||
<footer class="status-bar">
|
||||
<div class="status-line">
|
||||
<div class="live-block">
|
||||
<span class="live-dot"></span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<span class="sep">·</span>
|
||||
<span class="scene-name">OPHI-118 // DESKTOP</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="hud-tag"><span class="dim">SIGNAL</span><span id="signal" data-profile="normal">▮▮▮▯▯</span></span>
|
||||
<span class="sep">·</span>
|
||||
<span class="hud-tag"><span class="dim">UTC</span><span id="clock">--:--:--</span></span>
|
||||
</div>
|
||||
<div class="status-line meta-line">
|
||||
<span class="icon">◷</span>
|
||||
<span class="elapsed" id="elapsed">00:00:00</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="icon" title="microphone">🎙</span>
|
||||
<span style="color: var(--term-fg);">ON</span>
|
||||
</div>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const pad = HUD.pad;
|
||||
|
||||
const streamStart = Date.now();
|
||||
function tickElapsed() {
|
||||
const s = Math.floor((Date.now() - streamStart) / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const mm = Math.floor((s / 60) % 60);
|
||||
const ss = s % 60;
|
||||
document.getElementById('elapsed').textContent = `${pad(h)}:${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
tickElapsed(); setInterval(tickElapsed, 1000);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const CAM_SOURCE = params.get('camera') || 'Camera';
|
||||
const SCR_SOURCE = params.get('screen') || 'Screen Capture (PipeWire)';
|
||||
const SCENE_LOCK = params.get('scene') || null;
|
||||
|
||||
const camEl = document.getElementById('cam');
|
||||
const scrEl = document.getElementById('scr');
|
||||
const rootStyle = document.documentElement.style;
|
||||
|
||||
function renderedSize(t) {
|
||||
const useBounds = t.boundsType
|
||||
&& t.boundsType !== 'OBS_BOUNDS_NONE'
|
||||
&& (t.boundsWidth ?? 0) > 0;
|
||||
if (useBounds) return { w: t.boundsWidth, h: t.boundsHeight };
|
||||
return {
|
||||
w: (t.sourceWidth ?? 0) * (t.scaleX ?? 1),
|
||||
h: (t.sourceHeight ?? 0) * (t.scaleY ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFrame(obs, sourceName, frameEl, varPrefix) {
|
||||
const state = { sceneName: null, itemId: null };
|
||||
const setVisible = (v) => frameEl.classList.toggle('off', !v);
|
||||
const setTransform = (t) => {
|
||||
if (!t) return;
|
||||
const { w, h } = renderedSize(t);
|
||||
if (!(w > 0 && h > 0)) return;
|
||||
rootStyle.setProperty(`--${varPrefix}-x`, `${t.positionX}px`);
|
||||
rootStyle.setProperty(`--${varPrefix}-y`, `${t.positionY}px`);
|
||||
rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`);
|
||||
rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`);
|
||||
};
|
||||
return {
|
||||
async rebind(sceneName) {
|
||||
state.sceneName = sceneName;
|
||||
state.itemId = null;
|
||||
if (!sceneName) { setVisible(false); return; }
|
||||
try {
|
||||
const r = await obs.call('GetSceneItemId', { sceneName, sourceName });
|
||||
state.itemId = r.sceneItemId;
|
||||
const e = await obs.call('GetSceneItemEnabled', { sceneName, sceneItemId: state.itemId });
|
||||
setVisible(e.sceneItemEnabled);
|
||||
const t = await obs.call('GetSceneItemTransform', { sceneName, sceneItemId: state.itemId });
|
||||
setTransform(t.sceneItemTransform);
|
||||
} catch {
|
||||
setVisible(false);
|
||||
}
|
||||
},
|
||||
handleEvent(d) {
|
||||
if (d.eventData?.sceneName !== state.sceneName
|
||||
|| d.eventData?.sceneItemId !== state.itemId) return;
|
||||
if (d.eventType === 'SceneItemEnableStateChanged') {
|
||||
setVisible(d.eventData.sceneItemEnabled);
|
||||
} else if (d.eventType === 'SceneItemTransformChanged') {
|
||||
setTransform(d.eventData.sceneItemTransform);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
OBSWSBootstrap.connect({
|
||||
onConnect: async (obs) => {
|
||||
const camFrame = makeFrame(obs, CAM_SOURCE, camEl, 'cam');
|
||||
const scrFrame = makeFrame(obs, SCR_SOURCE, scrEl, 'scr');
|
||||
let scene = SCENE_LOCK;
|
||||
if (!scene) {
|
||||
try {
|
||||
const r = await obs.call('GetCurrentProgramScene');
|
||||
scene = r.currentProgramSceneName || r.sceneName || null;
|
||||
} catch { /* leave null */ }
|
||||
}
|
||||
await Promise.all([camFrame.rebind(scene), scrFrame.rebind(scene)]);
|
||||
obs.addEventListener('event', async (ev) => {
|
||||
const d = ev.detail || {};
|
||||
if (!SCENE_LOCK && d.eventType === 'CurrentProgramSceneChanged') {
|
||||
const next = d.eventData?.sceneName;
|
||||
await Promise.all([camFrame.rebind(next), scrFrame.rebind(next)]);
|
||||
return;
|
||||
}
|
||||
camFrame.handleEvent(d);
|
||||
scrFrame.handleEvent(d);
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
175
webapp/resources/views/overlays/game.blade.php
Normal file
175
webapp/resources/views/overlays/game.blade.php
Normal file
@@ -0,0 +1,175 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'GAME')
|
||||
|
||||
@section('body-class', 'camera-hud-body')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
:root {
|
||||
--pad-y: 40px;
|
||||
--pad-x: 72px;
|
||||
/* Camera frame defaults — auto-synced from OBS via WS. */
|
||||
--cam-x: 1855px;
|
||||
--cam-y: 128px;
|
||||
--cam-w: 580px;
|
||||
--cam-h: 326px;
|
||||
--cam-pad: 10px;
|
||||
}
|
||||
.hud {
|
||||
position: absolute;
|
||||
top: var(--pad-y);
|
||||
left: var(--pad-x);
|
||||
right: var(--pad-x);
|
||||
font-size: 20px;
|
||||
text-shadow: 0 0 8px rgba(7, 8, 13, 0.9), 0 0 4px rgba(7, 8, 13, 0.9);
|
||||
}
|
||||
.hud .group { gap: 24px; }
|
||||
.hud .group > span { gap: 10px; }
|
||||
.hud .led { width: 10px; height: 10px; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@push('data-scripts')
|
||||
<script>window.__LOADING = @json($manifest);</script>
|
||||
@endpush
|
||||
|
||||
@section('crt')
|
||||
{{-- no CRT overlays on the transparent game HUD --}}
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@include('partials.hud-strip', [
|
||||
'variant' => 'live',
|
||||
'idText' => 'OPHI-118 // LIVE',
|
||||
])
|
||||
|
||||
@include('partials.camera-frame', ['id' => 'cam', 'label' => 'CAM 01'])
|
||||
|
||||
<footer class="status-bar">
|
||||
<div class="status-line">
|
||||
<div class="live-block">
|
||||
<span class="live-dot"></span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<span class="sep">·</span>
|
||||
<span class="game-name" id="game-name">—</span>
|
||||
<span class="sep" id="game-mode-sep">·</span>
|
||||
<span class="game-mode" id="game-mode">—</span>
|
||||
</div>
|
||||
<div class="status-line meta-line">
|
||||
<span class="icon">◷</span>
|
||||
<span class="elapsed" id="elapsed">00:00:00</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="icon" id="mic-icon" title="microphone">🎙</span>
|
||||
<span id="mic-state">—</span>
|
||||
<span class="sep">·</span>
|
||||
<div class="np-block" id="np">
|
||||
<span class="arrow">▶</span>
|
||||
<span class="title" id="np-title">—</span>
|
||||
<span class="time" id="np-time">[--:-- / --:--]</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const pad = HUD.pad;
|
||||
|
||||
const m = window.__LOADING || {};
|
||||
const gameName = (m.game || '—').toUpperCase();
|
||||
const subtitle = m.subtitle ? m.subtitle.toUpperCase() : null;
|
||||
const cameraOn = m.camera ?? true;
|
||||
const micOn = m.microphone ?? true;
|
||||
|
||||
document.getElementById('game-name').textContent = gameName;
|
||||
if (subtitle) {
|
||||
document.getElementById('game-mode').textContent = subtitle;
|
||||
} else {
|
||||
document.getElementById('game-mode-sep').style.display = 'none';
|
||||
document.getElementById('game-mode').style.display = 'none';
|
||||
}
|
||||
const micEl = document.getElementById('mic-state');
|
||||
micEl.textContent = micOn ? 'ON' : 'MUTED';
|
||||
micEl.style.color = micOn ? 'var(--term-fg)' : 'var(--accent)';
|
||||
if (!cameraOn) document.getElementById('cam').classList.add('off');
|
||||
|
||||
const streamStart = Date.now();
|
||||
function tickElapsed() {
|
||||
const s = Math.floor((Date.now() - streamStart) / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const mm = Math.floor((s / 60) % 60);
|
||||
const ss = s % 60;
|
||||
document.getElementById('elapsed').textContent = `${pad(h)}:${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
tickElapsed(); setInterval(tickElapsed, 1000);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const CAM_SOURCE = params.get('camera') || 'Camera';
|
||||
const CAM_SCENE = params.get('scene') || 'Game';
|
||||
|
||||
function fmtTime(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
const npBlock = document.getElementById('np');
|
||||
const npTitle = document.getElementById('np-title');
|
||||
const npTime = document.getElementById('np-time');
|
||||
const camEl = document.getElementById('cam');
|
||||
const rootStyle = document.documentElement.style;
|
||||
const setCameraVisible = (visible) => camEl.classList.toggle('off', !visible);
|
||||
function setCameraTransform(t) {
|
||||
if (!t) return;
|
||||
const w = (t.sourceWidth ?? 0) * (t.scaleX ?? 1);
|
||||
const h = (t.sourceHeight ?? 0) * (t.scaleY ?? 1);
|
||||
if (!(w > 0 && h > 0)) return;
|
||||
rootStyle.setProperty('--cam-x', `${t.positionX}px`);
|
||||
rootStyle.setProperty('--cam-y', `${t.positionY}px`);
|
||||
rootStyle.setProperty('--cam-w', `${w}px`);
|
||||
rootStyle.setProperty('--cam-h', `${h}px`);
|
||||
}
|
||||
|
||||
async function syncCamera(obs) {
|
||||
let camItemId = null;
|
||||
try {
|
||||
const r = await obs.call('GetSceneItemId',
|
||||
{ sceneName: CAM_SCENE, sourceName: CAM_SOURCE });
|
||||
camItemId = r.sceneItemId;
|
||||
const e = await obs.call('GetSceneItemEnabled',
|
||||
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
||||
setCameraVisible(e.sceneItemEnabled);
|
||||
const t = await obs.call('GetSceneItemTransform',
|
||||
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
||||
setCameraTransform(t.sceneItemTransform);
|
||||
} catch (err) {
|
||||
console.warn(`[game] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
|
||||
return;
|
||||
}
|
||||
obs.addEventListener('event', (ev) => {
|
||||
const d = ev.detail || {};
|
||||
if (d.eventData?.sceneName !== CAM_SCENE
|
||||
|| d.eventData?.sceneItemId !== camItemId) return;
|
||||
if (d.eventType === 'SceneItemEnableStateChanged') {
|
||||
setCameraVisible(d.eventData.sceneItemEnabled);
|
||||
} else if (d.eventType === 'SceneItemTransformChanged') {
|
||||
setCameraTransform(d.eventData.sceneItemTransform);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
OBSWSBootstrap.connect({
|
||||
onConnect: async (obs) => {
|
||||
obs.onCustom('mpd:state', (s) => {
|
||||
if (!s.title) return;
|
||||
npBlock.classList.add('on');
|
||||
npTitle.textContent = s.title;
|
||||
npTime.textContent = `[${fmtTime(s.currentTime)} / ${fmtTime(s.duration)}]`;
|
||||
});
|
||||
await syncCamera(obs);
|
||||
},
|
||||
onClose: () => npBlock.classList.remove('on'),
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
267
webapp/resources/views/overlays/goodbye.blade.php
Normal file
267
webapp/resources/views/overlays/goodbye.blade.php
Normal file
@@ -0,0 +1,267 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'SIGN-OFF')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
body.overlay-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: 28px;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.terminal {
|
||||
width: 60vw;
|
||||
max-width: 1380px;
|
||||
height: 58vh;
|
||||
max-height: 740px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</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' => 'offair',
|
||||
'label' => 'OFF AIR',
|
||||
'idText' => 'OPHI-118 // SIGN-OFF',
|
||||
'signalGlyph' => '▮▮▯▯▯',
|
||||
'signalProfile' => 'degrading',
|
||||
])
|
||||
|
||||
<main class="stage">
|
||||
<div class="terminal">
|
||||
<div id="termOutput"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="banner" id="banner">
|
||||
<div class="label">— TRANSMISSION ENDED —</div>
|
||||
<div class="title" id="bannerTitle">GOOD BYE</div>
|
||||
<div class="sub" id="bannerSub">— THANKS FOR TUNING IN —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— PROJECT MANIFEST UNLOADED —</span>
|
||||
<span class="offair">■ OFF AIR</span>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const pad = HUD.pad;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const MODE_KEYS = ['repeat', 'random', 'single'];
|
||||
const lines = [
|
||||
{ kind: 'cmd', text: 'unloadproject --flush --persist-state' },
|
||||
{ kind: 'out', text: 'closing transmission envelope...' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: 'session :: archived' },
|
||||
{ kind: 'out', text: 'camera :: released' },
|
||||
{ kind: 'out', text: 'microphone :: released' },
|
||||
{ kind: 'out', text: 'capture :: released' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'dim', text: 'flushing buffers...' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: 'TRANSMISSION ENDED.' },
|
||||
{ kind: 'warn', text: '— see you on the next channel —' },
|
||||
];
|
||||
|
||||
const term = document.getElementById('termOutput');
|
||||
const MAX_TERM_LINES = 200;
|
||||
function pruneTerminal() {
|
||||
while (term.children.length > MAX_TERM_LINES) {
|
||||
const first = term.firstElementChild;
|
||||
if (!first || first === npLine) 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 === npLine) break;
|
||||
term.removeChild(first);
|
||||
}
|
||||
}
|
||||
function append(prompt, text, cls) {
|
||||
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.appendChild(div);
|
||||
pruneTerminal();
|
||||
return t;
|
||||
}
|
||||
async function typeCommand(text) {
|
||||
const t = append(PROMPT, '', 'term-out');
|
||||
for (const c of text) {
|
||||
t.textContent += c;
|
||||
await sleep(38);
|
||||
}
|
||||
await sleep(280);
|
||||
}
|
||||
|
||||
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 formatMusicCommand(playback) {
|
||||
const p = normalizePlayback(playback);
|
||||
const parts = ['music', '--keep-alive'];
|
||||
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(' ');
|
||||
}
|
||||
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = playlistData.tracks || [];
|
||||
let bootStarted = false;
|
||||
let bootFinished = false;
|
||||
let lastPlaybackModes = null;
|
||||
let pendingState = null;
|
||||
let modeLogChain = Promise.resolve();
|
||||
|
||||
let npLine = null;
|
||||
function ensureNowPlayingLine() {
|
||||
if (npLine) return npLine;
|
||||
npLine = document.createElement('div');
|
||||
npLine.className = 'term-line term-now-playing';
|
||||
npLine.innerHTML =
|
||||
'<span class="np-arrow">▶</span>' +
|
||||
'<span class="np-title">connecting to daemon…</span>' +
|
||||
'<span class="np-time">[--:-- / --:--]</span>';
|
||||
term.appendChild(npLine);
|
||||
return npLine;
|
||||
}
|
||||
function logBeforeNp(text, cls = 'term-out') {
|
||||
if (!npLine) { append('> ', text, cls); return; }
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const p = document.createElement('span'); p.className = 'term-prompt'; p.textContent = '> ';
|
||||
const t = document.createElement('span'); t.className = cls; t.textContent = text;
|
||||
div.appendChild(p); div.appendChild(t);
|
||||
term.insertBefore(div, npLine);
|
||||
pruneTerminal();
|
||||
}
|
||||
function fmtClock(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
|
||||
async function logModeChange(mode, enabled) {
|
||||
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
|
||||
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
|
||||
}
|
||||
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;
|
||||
}
|
||||
let lastTrackFile = null;
|
||||
function renderMusicState(s) {
|
||||
if (!npLine) ensureNowPlayingLine();
|
||||
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile) {
|
||||
logBeforeNp(`[audio] next: ${s.title || '?'}`);
|
||||
}
|
||||
if (s.file) lastTrackFile = s.file;
|
||||
npLine.querySelector('.np-title').textContent = s.title || '—';
|
||||
npLine.querySelector('.np-time').textContent =
|
||||
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
||||
}
|
||||
|
||||
async function bootFromState(s) {
|
||||
if (bootStarted) return;
|
||||
bootStarted = true;
|
||||
lastPlaybackModes = normalizePlayback(s.playback);
|
||||
await typeCommand(formatMusicCommand(s.playback));
|
||||
if (allTracks.length === 0) {
|
||||
append('> ', '[audio] no tracks queued', 'term-dim');
|
||||
bootFinished = true;
|
||||
return;
|
||||
}
|
||||
append('> ', `[audio] queue retained: ${allTracks.length} tracks`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] standby for transmission ✓', 'term-out');
|
||||
bootFinished = true;
|
||||
if (pendingState) renderMusicState(pendingState);
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
append('', '[audio] waiting for mpd state', 'term-dim');
|
||||
OBSWSBootstrap.onState((s) => {
|
||||
pendingState = s;
|
||||
if (!bootStarted) { bootFromState(s); return; }
|
||||
if (!bootFinished) return;
|
||||
renderMusicState(s);
|
||||
trackModeChanges(s.playback);
|
||||
}, {
|
||||
onClose: () => logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim'),
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
for (const ln of lines) {
|
||||
if (ln.kind === 'cmd') {
|
||||
await typeCommand(ln.text);
|
||||
} else if (ln.kind === 'gap') {
|
||||
append('', ' ', '');
|
||||
await sleep(80);
|
||||
} else {
|
||||
const cls = ln.kind === 'dim' ? 'term-dim' : ln.kind === 'warn' ? 'term-warn' : 'term-out';
|
||||
append('> ', ln.text, cls);
|
||||
await sleep(170);
|
||||
}
|
||||
}
|
||||
await sleep(400);
|
||||
startMusic();
|
||||
})();
|
||||
|
||||
// "OFFLINE FOR" counter that takes over the banner sub-line after 6s.
|
||||
const startMs = Date.now();
|
||||
const subEl = document.getElementById('bannerSub');
|
||||
const tickOffline = () => {
|
||||
const sec = Math.floor((Date.now() - startMs) / 1000);
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
const t = h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
||||
subEl.textContent = `— OFFLINE FOR ${t} —`;
|
||||
};
|
||||
setTimeout(() => { tickOffline(); setInterval(tickOffline, 500); }, 6000);
|
||||
</script>
|
||||
@endsection
|
||||
163
webapp/resources/views/overlays/landing.blade.php
Normal file
163
webapp/resources/views/overlays/landing.blade.php
Normal file
@@ -0,0 +1,163 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'STAND BY')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
body.overlay-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: 24px;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
}
|
||||
.mark {
|
||||
width: 360px; height: 360px;
|
||||
filter: drop-shadow(0 0 22px rgba(58, 209, 255, 0.35));
|
||||
}
|
||||
.bars {
|
||||
display: flex;
|
||||
width: 720px;
|
||||
height: 14px;
|
||||
opacity: 0.32;
|
||||
}
|
||||
.bars i { flex: 1; display: block; }
|
||||
.bars i:nth-child(1) { background: #c7c7c7; }
|
||||
.bars i:nth-child(2) { background: #c7c700; }
|
||||
.bars i:nth-child(3) { background: #00c7c7; }
|
||||
.bars i:nth-child(4) { background: #00c700; }
|
||||
.bars i:nth-child(5) { background: #c700c7; }
|
||||
.bars i:nth-child(6) { background: #c70000; }
|
||||
.bars i:nth-child(7) { background: #0000c7; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@push('data-scripts')
|
||||
<script>window.__TEL = @json($telemetry);</script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@include('partials.hud-strip', [
|
||||
'variant' => 'live',
|
||||
'label' => 'TRANSMISSION',
|
||||
'extraIdSlot' => '<span class="station"><span class="station-id">OPHI-118</span><span class="sep">//</span><span class="rig" id="rigName">--</span></span>',
|
||||
])
|
||||
|
||||
<main class="stage">
|
||||
<svg class="mark" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="100" cy="100" r="92" fill="none" stroke="#3ad1ff" stroke-width="1.2" opacity="0.35"/>
|
||||
<circle cx="100" cy="100" r="80" fill="none" stroke="#3ad1ff" stroke-width="2.5"/>
|
||||
<g stroke="#3ad1ff" stroke-width="2" opacity="0.55">
|
||||
<line x1="100" y1="8" x2="100" y2="22"/>
|
||||
<line x1="100" y1="178" x2="100" y2="192"/>
|
||||
<line x1="8" y1="100" x2="22" y2="100"/>
|
||||
<line x1="178" y1="100" x2="192" y2="100"/>
|
||||
</g>
|
||||
<polygon points="100,52 148,140 52,140"
|
||||
fill="none" stroke="#e63a2e" stroke-width="3.5" stroke-linejoin="miter"/>
|
||||
<circle cx="100" cy="116" r="4" fill="#ffd000"/>
|
||||
</svg>
|
||||
<div class="bars"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</main>
|
||||
|
||||
<section class="standby">
|
||||
<div class="big">PLEASE STAND BY</div>
|
||||
<div class="sub">— DO NOT ADJUST YOUR RECEIVER —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— TELEMETRY —</span>
|
||||
<span class="warn">▲ AUDIO ACTIVE</span>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const rigNameEl = document.getElementById('rigName');
|
||||
const RIG_NAME = (window.__TEL && window.__TEL.rig) || 'UNKNOWN';
|
||||
const upperRig = String(RIG_NAME).toUpperCase();
|
||||
const scrambleRig = ({ initial = false } = {}) => {
|
||||
if (!upperRig.length) { rigNameEl.textContent = '--'; return; }
|
||||
HUD.scrambleReveal(rigNameEl, upperRig, {
|
||||
scrambleMs: initial ? 600 : 360, stepMs: 50, resolveStepMs: 60,
|
||||
});
|
||||
};
|
||||
setTimeout(() => scrambleRig({ initial: true }), 600);
|
||||
setInterval(scrambleRig, 9000);
|
||||
|
||||
// Rotating telemetry footer.
|
||||
const rigEl = document.getElementById('rig');
|
||||
const tel = window.__TEL || null;
|
||||
const shortCpu = (m) => (m || '')
|
||||
.replace(/\(R\)|\(TM\)/g, '')
|
||||
.replace(/\s+\S+-Core\s+Processor\b/i, '')
|
||||
.replace(/\s+Processor\b/i, '')
|
||||
.replace(/\s+CPU\b.*$/i, '')
|
||||
.replace(/\s+@.*$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const lines = [
|
||||
() => {
|
||||
if (!tel?.cpu?.model) return null;
|
||||
let s = `CPU · ${shortCpu(tel.cpu.model)}`;
|
||||
if (tel.cpu.threads) s += ` · ${tel.cpu.threads}T`;
|
||||
if (tel.mem?.totalGB) s += ` · MEM ${tel.mem.totalGB} GB`;
|
||||
return s;
|
||||
},
|
||||
() => {
|
||||
if (!tel?.gpu?.name) return null;
|
||||
let s = `GPU · ${tel.gpu.name}`;
|
||||
if (tel.gpu.vramTotalMB) s += ` · ${(tel.gpu.vramTotalMB / 1024).toFixed(0)} GB VRAM`;
|
||||
return s;
|
||||
},
|
||||
() => {
|
||||
const o = tel?.obs;
|
||||
if (!o?.output?.w) return null;
|
||||
let s = `OUTPUT · ${o.output.w}×${o.output.h}`;
|
||||
if (o.fps) s += ` @ ${o.fps} FPS`;
|
||||
if (o.stream?.encoder) s += ` · ${o.stream.encoder}`;
|
||||
return s;
|
||||
},
|
||||
() => {
|
||||
const s = tel?.obs?.stream;
|
||||
if (!s) return null;
|
||||
const parts = [];
|
||||
if (s.rateControl) parts.push(s.rateControl);
|
||||
if (s.bitrateKbps) parts.push(`${s.bitrateKbps} kbps`);
|
||||
if (s.profile) parts.push(s.profile.toUpperCase());
|
||||
return parts.length ? `STREAM · ${parts.join(' · ')}` : null;
|
||||
},
|
||||
() => tel?.host?.kernel ? `KERNEL · ${tel.host.kernel}` : null,
|
||||
];
|
||||
let lineIdx = 0;
|
||||
const nextLine = () => {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const v = lines[lineIdx]();
|
||||
if (v) return v;
|
||||
lineIdx = (lineIdx + 1) % lines.length;
|
||||
}
|
||||
return '— TELEMETRY OFFLINE —';
|
||||
};
|
||||
rigEl.textContent = nextLine();
|
||||
const swapLine = () => {
|
||||
const text = nextLine();
|
||||
const len = Math.max(text.length, 1);
|
||||
HUD.scrambleReveal(rigEl, text, {
|
||||
scrambleMs: 280,
|
||||
stepMs: 45,
|
||||
resolveStepMs: Math.max(15, Math.floor(700 / len)),
|
||||
});
|
||||
};
|
||||
setInterval(() => {
|
||||
lineIdx = (lineIdx + 1) % lines.length;
|
||||
swapLine();
|
||||
}, 6000);
|
||||
</script>
|
||||
@endsection
|
||||
294
webapp/resources/views/overlays/loading.blade.php
Normal file
294
webapp/resources/views/overlays/loading.blade.php
Normal file
@@ -0,0 +1,294 @@
|
||||
@extends('layouts.overlay')
|
||||
|
||||
@section('title', 'PROJECT LOADING')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
body.overlay-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: 28px;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.terminal {
|
||||
width: 60vw;
|
||||
max-width: 1380px;
|
||||
height: 62vh;
|
||||
max-height: 820px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@push('data-scripts')
|
||||
<script>
|
||||
window.__LOADING = @json($manifest);
|
||||
window.__TEL = @json($telemetry);
|
||||
window.__TRACK_COUNT = @json($trackCount);
|
||||
</script>
|
||||
<script src="{{ asset('data/playlist.js') }}"></script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@include('partials.hud-strip', [
|
||||
'variant' => 'live',
|
||||
'idText' => 'OPHI-118 // PROJECT LOAD',
|
||||
])
|
||||
|
||||
<main class="stage">
|
||||
<div class="terminal">
|
||||
<div id="termOutput"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="countdown" id="cd">
|
||||
<div class="label">— STARTING IN —</div>
|
||||
<div class="digits" id="cdDigits">--:--</div>
|
||||
<div class="sub">— TRANSMISSION INCOMING —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— PROJECT MANIFEST LOADED —</span>
|
||||
<span class="warn">▲ ARMED</span>
|
||||
</footer>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
'use strict';
|
||||
const pad = HUD.pad;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const data = window.__LOADING || {};
|
||||
const onOff = b => (b ? 'ENABLED' : 'DISABLED');
|
||||
const upper = s => (s || '').toUpperCase();
|
||||
const cdMin = data.countdownMin ?? 5;
|
||||
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const MODE_KEYS = ['repeat', 'random', 'single'];
|
||||
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
|
||||
const lines = [
|
||||
{ kind: 'cmd', text: 'loadproject --manifest' },
|
||||
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
|
||||
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
|
||||
{ kind: 'out', text: `camera :: ${onOff(data.camera ?? true)}` },
|
||||
{ kind: 'out', text: `microphone :: ${onOff(data.microphone ?? true)}` },
|
||||
{ kind: 'out', text: `countdown :: ${pad(Math.round(cdMin))}:00` },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'dim', text: 'all systems nominal' },
|
||||
{ kind: 'out', text: 'READY.' },
|
||||
];
|
||||
|
||||
const term = document.getElementById('termOutput');
|
||||
const MAX_TERM_LINES = 200;
|
||||
function pruneTerminal() {
|
||||
while (term.children.length > MAX_TERM_LINES) {
|
||||
const first = term.firstElementChild;
|
||||
if (!first || first === npLine) break;
|
||||
term.removeChild(first);
|
||||
}
|
||||
}
|
||||
function append(prompt, text, cls) {
|
||||
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.appendChild(div);
|
||||
pruneTerminal();
|
||||
return t;
|
||||
}
|
||||
|
||||
async function typeCommand(text) {
|
||||
const t = append(PROMPT, '', 'term-out');
|
||||
for (const c of text) {
|
||||
t.textContent += c;
|
||||
await sleep(38);
|
||||
}
|
||||
await sleep(280);
|
||||
}
|
||||
|
||||
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 formatMusicCommand(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(' ');
|
||||
}
|
||||
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = playlistData.tracks || [];
|
||||
let bootStarted = false;
|
||||
let bootFinished = false;
|
||||
let lastPlaybackModes = null;
|
||||
let pendingState = null;
|
||||
let modeLogChain = Promise.resolve();
|
||||
|
||||
let npLine = null;
|
||||
function ensureNowPlayingLine() {
|
||||
if (npLine) return npLine;
|
||||
npLine = document.createElement('div');
|
||||
npLine.className = 'term-line term-now-playing';
|
||||
npLine.innerHTML =
|
||||
'<span class="np-arrow">▶</span>' +
|
||||
'<span class="np-title">connecting to daemon…</span>' +
|
||||
'<span class="np-time">[--:-- / --:--]</span>';
|
||||
term.appendChild(npLine);
|
||||
return npLine;
|
||||
}
|
||||
function logBeforeNp(text, cls = 'term-out') {
|
||||
if (!npLine) { append('> ', text, cls); return; }
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const p = document.createElement('span'); p.className = 'term-prompt'; p.textContent = '> ';
|
||||
const t = document.createElement('span'); t.className = cls; t.textContent = text;
|
||||
div.appendChild(p); div.appendChild(t);
|
||||
term.insertBefore(div, npLine);
|
||||
pruneTerminal();
|
||||
}
|
||||
function fmtClock(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
|
||||
async function logModeChange(mode, enabled) {
|
||||
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
|
||||
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let lastTrackFile = null;
|
||||
function renderMusicState(s) {
|
||||
if (!npLine) ensureNowPlayingLine();
|
||||
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile) {
|
||||
logBeforeNp(`[audio] next: ${s.title || '?'}`);
|
||||
}
|
||||
if (s.file) lastTrackFile = s.file;
|
||||
npLine.querySelector('.np-title').textContent = s.title || '—';
|
||||
npLine.querySelector('.np-time').textContent =
|
||||
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
||||
}
|
||||
|
||||
async function bootFromState(s) {
|
||||
if (bootStarted) return;
|
||||
bootStarted = true;
|
||||
lastPlaybackModes = normalizePlayback(s.playback);
|
||||
|
||||
await typeCommand(formatMusicCommand(s.playback));
|
||||
if (allTracks.length === 0) {
|
||||
append('> ', '[audio] no tracks queued — run `php artisan rig:playlist`', 'term-dim');
|
||||
bootFinished = true;
|
||||
return;
|
||||
}
|
||||
|
||||
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', `[audio] uplink to daemon @ ophi-118://${rigName.toLowerCase()}.audio.bus`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] chat ops armed :: !skip · !queue · !info', 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] standby for transmission ✓', 'term-out');
|
||||
bootFinished = true;
|
||||
if (pendingState) renderMusicState(pendingState);
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
append('', '[audio] waiting for mpd state', 'term-dim');
|
||||
OBSWSBootstrap.onState((s) => {
|
||||
pendingState = s;
|
||||
if (!bootStarted) {
|
||||
bootFromState(s);
|
||||
return;
|
||||
}
|
||||
if (!bootFinished) return;
|
||||
renderMusicState(s);
|
||||
trackModeChanges(s.playback);
|
||||
}, {
|
||||
onClose: () => logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim'),
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
for (const ln of lines) {
|
||||
if (ln.kind === 'cmd') {
|
||||
await typeCommand(ln.text);
|
||||
} else if (ln.kind === 'gap') {
|
||||
append('', ' ', '');
|
||||
await sleep(80);
|
||||
} else {
|
||||
append('> ', ln.text, ln.kind === 'dim' ? 'term-dim' : 'term-out');
|
||||
await sleep(170);
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
startMusic();
|
||||
})();
|
||||
|
||||
// Countdown
|
||||
const startMs = Date.now();
|
||||
const totalMs = cdMin * 60 * 1000;
|
||||
const cdEl = document.getElementById('cd');
|
||||
const cdDigits = document.getElementById('cdDigits');
|
||||
const cdLabel = cdEl.querySelector('.label');
|
||||
const cdSub = cdEl.querySelector('.sub');
|
||||
let readyShown = false;
|
||||
const tickCountdown = () => {
|
||||
const remaining = Math.max(0, totalMs - (Date.now() - startMs));
|
||||
if (remaining <= 0) {
|
||||
if (!readyShown) {
|
||||
readyShown = true;
|
||||
cdEl.classList.add('ready');
|
||||
cdLabel.textContent = '— STREAM ACTIVE —';
|
||||
cdDigits.textContent = 'READY';
|
||||
cdSub.textContent = '— TRANSMISSION GO —';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const m = Math.floor(remaining / 60000);
|
||||
const s = Math.floor((remaining % 60000) / 1000);
|
||||
cdDigits.textContent = `${pad(m)}:${pad(s)}`;
|
||||
};
|
||||
tickCountdown();
|
||||
setInterval(tickCountdown, 250);
|
||||
</script>
|
||||
@endsection
|
||||
195
webapp/resources/views/overlays/music/box.blade.php
Normal file
195
webapp/resources/views/overlays/music/box.blade.php
Normal 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 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
|
||||
186
webapp/resources/views/overlays/music/cover.blade.php
Normal file
186
webapp/resources/views/overlays/music/cover.blade.php
Normal 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>
|
||||
180
webapp/resources/views/overlays/music/daemon.blade.php
Normal file
180
webapp/resources/views/overlays/music/daemon.blade.php
Normal 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>
|
||||
517
webapp/resources/views/overlays/music/nc.blade.php
Normal file
517
webapp/resources/views/overlays/music/nc.blade.php
Normal 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
|
||||
196
webapp/resources/views/overlays/music/widget.blade.php
Normal file
196
webapp/resources/views/overlays/music/widget.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user