263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
/* Vista Cues — client part.
|
||||
|
|
|
|||
|
|
One tile: the cue list most recently fired, big, with how long ago and
|
|||
|
|
who fired it; under it the other lists still live, the recent notes, and
|
|||
|
|
which senders we are in session with. All of it comes from the server's
|
|||
|
|
one `state` event; between events only the "how long ago" clocks move. */
|
|||
|
|
|
|||
|
|
const MINUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9"/></svg>';
|
|||
|
|
const PLUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9M8 3.5v9"/></svg>';
|
|||
|
|
const CLEAR_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 13.5h7.3"/><path d="M2.8 9.6 8.9 3.5a1.4 1.4 0 0 1 2 0l2.1 2.1a1.4 1.4 0 0 1 0 2l-5.9 5.9H5.6L2.8 10.7a.8.8 0 0 1 0-1.1Z"/><path d="m6 6.4 4.1 4.1"/></svg>';
|
|||
|
|
|
|||
|
|
const ACTION_LABEL = {
|
|||
|
|
go: 'Go',
|
|||
|
|
release: 'Release',
|
|||
|
|
releaseAll: 'Release all',
|
|||
|
|
pause: 'Pause',
|
|||
|
|
back: 'Back',
|
|||
|
|
flash: 'Flash',
|
|||
|
|
cleared: 'Cleared',
|
|||
|
|
'': '—',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function esc(s) {
|
|||
|
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 75 000 ms → "1:15", 3 725 000 → "1:02:05". */
|
|||
|
|
function since(ms) {
|
|||
|
|
const s = Math.max(0, Math.floor(ms / 1000));
|
|||
|
|
const h = Math.floor(s / 3600);
|
|||
|
|
const m = Math.floor((s % 3600) / 60);
|
|||
|
|
const ss = String(s % 60).padStart(2, '0');
|
|||
|
|
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clockTime(ms) {
|
|||
|
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function create({ root, moduleApi }) {
|
|||
|
|
const prefs = () => moduleApi.instanceSettings || {};
|
|||
|
|
const on = (k, dflt = true) => (prefs()[k] === undefined ? dflt : prefs()[k] !== false);
|
|||
|
|
const scale = () => Math.min(2.5, Math.max(0.5, Number(prefs().textScale) || 1));
|
|||
|
|
|
|||
|
|
let state = null;
|
|||
|
|
let skew = 0; // server clock − our clock
|
|||
|
|
let stream = null;
|
|||
|
|
let tick = null;
|
|||
|
|
let el = {};
|
|||
|
|
let resizeObserver = null;
|
|||
|
|
|
|||
|
|
const serverNow = () => Date.now() + skew;
|
|||
|
|
|
|||
|
|
/* ── fitting the big name to its box ─────────────────────────────── */
|
|||
|
|
|
|||
|
|
function fit(node) {
|
|||
|
|
if (!node || !node.isConnected) return;
|
|||
|
|
node.style.fontSize = '';
|
|||
|
|
const avail = node.parentElement ? node.parentElement.clientWidth : node.clientWidth;
|
|||
|
|
let size = parseFloat(getComputedStyle(node).fontSize) || 16;
|
|||
|
|
for (let i = 0; i < 3 && node.scrollWidth > avail + 0.5 && size > 10; i += 1) {
|
|||
|
|
size = Math.max(10, size * (avail / node.scrollWidth) * 0.98);
|
|||
|
|
node.style.fontSize = `${size}px`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── drawing ─────────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
function build() {
|
|||
|
|
root.innerHTML = `
|
|||
|
|
<div class="vc-root">
|
|||
|
|
<div class="vc-offline" hidden></div>
|
|||
|
|
<div class="vc-body">
|
|||
|
|
<div class="vc-now">
|
|||
|
|
<div class="vc-kicker"><span class="vc-pill"></span><span class="vc-ago"></span></div>
|
|||
|
|
<div class="vc-name-wrap"><div class="vc-name"></div></div>
|
|||
|
|
<div class="vc-meta"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="vc-active"></div>
|
|||
|
|
<div class="vc-recent"></div>
|
|||
|
|
<div class="vc-sources"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>`;
|
|||
|
|
const q = (s) => root.querySelector(s);
|
|||
|
|
el = {
|
|||
|
|
root: q('.vc-root'),
|
|||
|
|
offline: q('.vc-offline'),
|
|||
|
|
pill: q('.vc-pill'),
|
|||
|
|
ago: q('.vc-ago'),
|
|||
|
|
name: q('.vc-name'),
|
|||
|
|
meta: q('.vc-meta'),
|
|||
|
|
active: q('.vc-active'),
|
|||
|
|
recent: q('.vc-recent'),
|
|||
|
|
sources: q('.vc-sources'),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function render() {
|
|||
|
|
if (!el.root) return;
|
|||
|
|
el.root.style.setProperty('--vc-scale', String(scale()));
|
|||
|
|
const s = state;
|
|||
|
|
if (!s) {
|
|||
|
|
el.name.textContent = 'Connecting…';
|
|||
|
|
el.root.dataset.tone = 'muted';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const connected = s.sources.filter((x) => x.connected);
|
|||
|
|
const offline = !s.listening || !connected.length;
|
|||
|
|
el.root.classList.toggle('is-offline', offline);
|
|||
|
|
el.offline.hidden = !offline;
|
|||
|
|
el.offline.textContent = !s.listening ? 'Not listening' : 'No MIDI session';
|
|||
|
|
|
|||
|
|
// Now
|
|||
|
|
const cur = s.current;
|
|||
|
|
if (cur) {
|
|||
|
|
el.root.dataset.tone = cur.state === 'paused' ? 'warn' : 'ok';
|
|||
|
|
el.pill.textContent = cur.state === 'paused' ? 'Paused' : 'Live';
|
|||
|
|
el.pill.hidden = false;
|
|||
|
|
if (el.name.textContent !== cur.name) { el.name.textContent = cur.name; fit(el.name); }
|
|||
|
|
const bits = [];
|
|||
|
|
if (cur.goes > 1) bits.push(`Go ×${cur.goes}`);
|
|||
|
|
if (cur.source) bits.push(cur.source);
|
|||
|
|
if (cur.lastAction === 'back') bits.push('Back');
|
|||
|
|
el.meta.textContent = bits.join(' · ');
|
|||
|
|
} else {
|
|||
|
|
el.root.dataset.tone = 'muted';
|
|||
|
|
el.pill.hidden = true;
|
|||
|
|
const msg = s.rules ? 'No cue list live' : 'No cue lists mapped';
|
|||
|
|
if (el.name.textContent !== msg) { el.name.textContent = msg; fit(el.name); }
|
|||
|
|
el.meta.textContent = s.rules ? (s.log.length ? '' : 'Waiting for the first cue') : 'Add the cue sheet in /admin → Vista Cues';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Other live lists
|
|||
|
|
el.active.hidden = !on('showActive') || !s.active.length;
|
|||
|
|
if (!el.active.hidden) {
|
|||
|
|
el.active.innerHTML = s.active.map((l) => `
|
|||
|
|
<div class="vc-chip${l.state === 'paused' ? ' is-paused' : ''}" title="${esc(l.source || '')}">
|
|||
|
|
${l.group ? `<span class="vc-chip-group">${esc(l.group)}</span>` : ''}
|
|||
|
|
<span class="vc-chip-name">${esc(l.name)}</span>
|
|||
|
|
<span class="vc-chip-ago" data-since="${l.lastAt}"></span>
|
|||
|
|
</div>`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Recent
|
|||
|
|
const showUnmapped = on('showUnmapped', false);
|
|||
|
|
const rows = s.log.filter((e) => e.mapped || showUnmapped).slice(0, 12);
|
|||
|
|
el.recent.hidden = !on('showRecent') || !rows.length;
|
|||
|
|
if (!el.recent.hidden) {
|
|||
|
|
el.recent.innerHTML = rows.map((e) => `
|
|||
|
|
<div class="vc-row${e.mapped ? '' : ' is-unmapped'}" data-action="${esc(e.action)}">
|
|||
|
|
<span class="vc-row-time">${esc(clockTime(e.at))}</span>
|
|||
|
|
<span class="vc-row-action">${esc(e.mapped ? (ACTION_LABEL[e.action] ?? e.action) : 'Unmapped')}</span>
|
|||
|
|
<span class="vc-row-list">${esc(e.mapped ? e.list : e.trigger)}</span>
|
|||
|
|
<span class="vc-row-source">${esc(e.source)}</span>
|
|||
|
|
</div>`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Senders
|
|||
|
|
el.sources.hidden = !on('showSources') || !s.sources.length;
|
|||
|
|
if (!el.sources.hidden) {
|
|||
|
|
el.sources.innerHTML = s.sources.map((x) => `
|
|||
|
|
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
|
|||
|
|
<span class="vc-dot"></span>${esc(x.label)}
|
|||
|
|
</span>`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateClocks();
|
|||
|
|
reportStatus();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateClocks() {
|
|||
|
|
if (!state || !el.root) return;
|
|||
|
|
const now = serverNow();
|
|||
|
|
const cur = state.current;
|
|||
|
|
el.ago.textContent = cur ? since(now - cur.lastAt) : '';
|
|||
|
|
el.ago.title = cur ? `Fired ${clockTime(cur.firedAt)}` : '';
|
|||
|
|
root.querySelectorAll('[data-since]').forEach((n) => {
|
|||
|
|
n.textContent = since(now - Number(n.dataset.since));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function reportStatus() {
|
|||
|
|
const s = state;
|
|||
|
|
if (!s) return;
|
|||
|
|
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
|||
|
|
const connected = s.sources.filter((x) => x.connected);
|
|||
|
|
const waiting = s.sources.filter((x) => !x.connected);
|
|||
|
|
if (!connected.length) {
|
|||
|
|
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
|||
|
|
return moduleApi.setStatus('error', why);
|
|||
|
|
}
|
|||
|
|
const msg = `Hearing ${connected.map((x) => x.label).join(', ')}${waiting.length ? ` · not ${waiting.map((x) => x.label).join(', ')}` : ''}`;
|
|||
|
|
return moduleApi.setStatus(waiting.length ? 'connecting' : 'ok', msg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function take(next) {
|
|||
|
|
if (!next) return;
|
|||
|
|
skew = Number(next.now) ? Number(next.now) - Date.now() : 0;
|
|||
|
|
state = next;
|
|||
|
|
render();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refresh() {
|
|||
|
|
try {
|
|||
|
|
const r = await moduleApi.fetch('/state');
|
|||
|
|
if (r.ok) take((await r.json()).state);
|
|||
|
|
} catch { /* the stream will say so */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── title bar ───────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
function bump(delta) {
|
|||
|
|
const next = Math.round(Math.min(2.5, Math.max(0.5, scale() + delta)) * 10) / 10;
|
|||
|
|
moduleApi.saveInstanceSettings({ textScale: next });
|
|||
|
|
render();
|
|||
|
|
fit(el.name);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
start() {
|
|||
|
|
build();
|
|||
|
|
render();
|
|||
|
|
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bump(-0.1) });
|
|||
|
|
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bump(0.1) });
|
|||
|
|
moduleApi.header.addButton({
|
|||
|
|
icon: CLEAR_SVG,
|
|||
|
|
title: 'Clear live lists',
|
|||
|
|
async onClick() {
|
|||
|
|
try { await moduleApi.fetch('/clear', { method: 'POST' }); } catch { /* status says why */ }
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
moduleApi.setStatus('connecting', 'Connecting…');
|
|||
|
|
stream = moduleApi.sse('/stream', {
|
|||
|
|
open() { refresh(); },
|
|||
|
|
error() { moduleApi.setStatus('error', 'Lost connection to ProdDash'); },
|
|||
|
|
events: {
|
|||
|
|
state(e) {
|
|||
|
|
try { take(JSON.parse(e.data)); } catch { /* malformed frame */ }
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
refresh();
|
|||
|
|
tick = setInterval(updateClocks, 1000);
|
|||
|
|
if (typeof ResizeObserver === 'function') {
|
|||
|
|
resizeObserver = new ResizeObserver(() => fit(el.name));
|
|||
|
|
resizeObserver.observe(root);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
stop() {
|
|||
|
|
clearInterval(tick);
|
|||
|
|
tick = null;
|
|||
|
|
if (stream) { stream.close(); stream = null; }
|
|||
|
|
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
|||
|
|
root.innerHTML = '';
|
|||
|
|
el = {};
|
|||
|
|
},
|
|||
|
|
onResize() {
|
|||
|
|
fit(el.name);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|