229 lines
8.5 KiB
HTML
229 lines
8.5 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>OPHI-118 / Music Daemon</title>
|
|
<style>
|
|
/* Daemon page — visible if the source is set visible in OBS, otherwise just
|
|
a hidden audio worker. The visible state block is for diagnostics: toggle
|
|
the source's eye-icon ON briefly to read it, OFF when everything works. */
|
|
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>
|
|
|
|
<script src="../vendor/obs-config.js"></script>
|
|
<script src="../vendor/obs-ws-mini.js"></script>
|
|
<script src="../loading/playlist.js"></script>
|
|
|
|
<script>
|
|
'use strict';
|
|
|
|
// ─────────────── State ───────────────
|
|
const data = window.__PLAYLIST || { tracks: [] };
|
|
const allTracks = data.tracks || [];
|
|
const audio = document.getElementById('player');
|
|
audio.volume = 1.0;
|
|
|
|
// Fisher-Yates shuffle, fresh every load.
|
|
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();
|
|
|
|
// ─────────────── Visible status block (debug aid) ───────────────
|
|
// Must come AFTER `queue`/`qIdx` are declared — refreshStatus() reads them.
|
|
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; }
|
|
|
|
// ─────────────── OBS WebSocket connection ───────────────
|
|
let obs = null;
|
|
async function connectOBS() {
|
|
if (!window.__OBSWS) {
|
|
setWs('vendor/obs-config.js MISSING — run scripts/setup.sh', '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();
|
|
|
|
// ─────────────── Broadcasting ───────────────
|
|
// Single 'mpd:state' event for everything. Listeners diff `index` to detect
|
|
// track changes; this means newly-connecting overlays get full state within
|
|
// ~1s of connecting (no special request/response handshake needed).
|
|
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(() => {});
|
|
}
|
|
|
|
// ─────────────── Playback ───────────────
|
|
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);
|
|
|
|
// Mid-track stall watchdog (currentTime not advancing while expected to play).
|
|
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);
|
|
|
|
// Periodic state broadcast — 1 Hz is enough for the visible time display
|
|
// and ensures any newly-connecting overlay catches up within a second.
|
|
setInterval(broadcastState, 1000);
|
|
|
|
// ─────────────── Command sources ───────────────
|
|
// 1) Bash CLI: writes loading/cmd.js → polled here via cache-busted <script>.
|
|
// First poll after page-load ignores the existing command (it was already
|
|
// consumed in the previous session).
|
|
let lastCmdId = null;
|
|
let firstCmdPoll = true;
|
|
function pollCmd() {
|
|
const old = document.getElementById('cmd-poll');
|
|
if (old) old.remove();
|
|
const s = document.createElement('script');
|
|
s.id = 'cmd-poll';
|
|
s.src = '../loading/cmd.js?t=' + Date.now();
|
|
s.onload = () => {
|
|
const cmd = window.__CMD;
|
|
if (!cmd) return;
|
|
if (firstCmdPoll) { lastCmdId = cmd.id; firstCmdPoll = false; return; }
|
|
if (cmd.id !== lastCmdId) {
|
|
lastCmdId = cmd.id;
|
|
handleCommand(cmd);
|
|
}
|
|
};
|
|
s.onerror = () => { firstCmdPoll = false; }; // file doesn't exist yet — fine
|
|
document.body.appendChild(s);
|
|
}
|
|
setInterval(pollCmd, 250);
|
|
pollCmd();
|
|
|
|
// 2) OBS WebSocket: any source can send mpd:cmd via OBSWSMini.broadcast().
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ─────────────── Boot ───────────────
|
|
if (queue.length > 0) {
|
|
playCurrent();
|
|
} else {
|
|
console.warn('[music] no tracks — run scripts/playlist.sh');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|