The four overlays that read /data/playlist.js (loading, goodbye,
music-box, music/nc) only ever consumed `tracks.length` for cosmetic
"LIBRARY :: N TRACKS" / "indexed N tracks" flavor text — all live
playback signal flows through bridges/mpd-state.py over OBS WS. The
142 KB JSON, ffprobe walk, and ETag-cached route were all producing
one integer that MPD itself already knows.
RigData::playlistCount() now shells `mpc stats` and parses "Songs: N",
returning 0 on any failure (treated by callers as empty library).
Removes RigPlaylistCommand, DataController::playlist(), the
/data/playlist.js route, the per-overlay <script src> + window.__PLAYLIST
shims, and the stale storage/data/playlist.json artifact.
AudioController still consumes config('rig.music_dirs') for the HTTP
audio stream — that's orthogonal and stays.
266 lines
8.5 KiB
PHP
266 lines
8.5 KiB
PHP
@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>
|
|
@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 trackCount = window.__TRACK_COUNT || 0;
|
|
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 (trackCount === 0) {
|
|
append('> ', '[audio] no tracks queued', 'term-dim');
|
|
bootFinished = true;
|
|
return;
|
|
}
|
|
append('> ', `[audio] queue retained: ${trackCount} 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
|