Vista Cues 1.0.0: which Vista 3 cue lists are live, from the network MIDI that fires them
Vista 3 reports nothing about what it is playing — its OSC only takes commands — so a booth with no lighting operator can't see the lighting computer. ProPresenter, the tracks rig and Companion fire Vista's cue lists with notes over macOS network MIDI sessions, and a session sends every note to every participant, so the module joins those sessions as one more listener (RTP-MIDI in plain Node, no helper) and hears what Vista hears. The admin's cue sheet turns notes into Go / release / pause / back / flash on named lists. Sheets with no release notes use [groups]: a Go replaces its group's live list, and the first group is the headline — the main scene — which takes over the look and clears the FX below it. The tile shows the headline list large with how long ago and who fired it, the other live lists as tags, recent notes, and which senders are connected. The module invites each configured sender and keeps re-inviting, accepts invitations from anyone, and advertises itself over Bonjour on macOS. A busy port (a config save re-initing before the old sockets close) is retried rather than failing. Default session port is 5040/5041, clear of the 5009 session on NA Ingest. tools/vista-midi-mock.js is a stand-in sender for development; modules/vista-cues/vista.test.js covers the parser, packet decoding and real loopback sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/* 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user