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>
265 lines
10 KiB
JavaScript
265 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' : ''}${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);
|
||
},
|
||
};
|
||
}
|