Files
ProdDash/modules/vista-cues/client.js
T
christianandClaude Opus 5.5 df2f0494f8 Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red
The Macs at NA are also on Wi-Fi and the Dante networks, and Bonjour lists each MIDI session once
per network. On 2026-10-20 the Tracks and Companion senders were set to Wi-Fi addresses, so the
lighting MIDI ProdDash hears rode Wi-Fi. With "Wired network" set (This machine; e.g. 10.1.0.0/16,
several with commas), a sender typed as a name or as an address outside it, and a session that
joins us from outside it, turn the admin status line and the tile's sender chip red, naming the
sender. It still joins, so the cues keep showing. Empty (the default) changes nothing; a typo in
the setting fails the module in /admin with a plain message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:14:44 -04:00

265 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (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);
const off = s.sources.filter((x) => x.offNetwork);
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
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);
},
};
}