The picker gives a heading only to modules that offer a list of tiles; a module without one is a bare line under whichever module comes before it, and alphabetically that is Slack. Vista Cues now offers one entry, "Live cue lists", so it reads as its own module. Tiles added before this have no variant and keep using the module's schema, so they still draw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Vista Cues — server part.
|
||
*
|
||
* Vista 3 cannot say what it is playing: its OSC is input-only and it sends
|
||
* no feedback. What it *does* have is a network MIDI session that
|
||
* ProPresenter and the tracks rig fire cue lists through. A session sends
|
||
* every message to every participant, so this module joins those sessions
|
||
* as one more participant (rtpmidi.js) and hears exactly what Vista hears.
|
||
* The admin's "Cue lists" sheet (mapping.js) turns each note into
|
||
* "Go on Walk-in", "Release Worship 1"…, and from those the module keeps
|
||
* which lists are live.
|
||
*
|
||
* GET /state → { state } (instant paint)
|
||
* GET /stream → SSE: `state` on every change
|
||
* POST /clear → forget which lists are live (when Vista was reset by hand)
|
||
*
|
||
* What the tile shows is what Vista was told, not what Vista confirmed: if
|
||
* Vista is closed or someone works the desk by hand, the two can differ.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const { spawn } = require('child_process');
|
||
const { Session } = require('./rtpmidi');
|
||
const { parseMapping, match, triggerLabel } = require('./mapping');
|
||
|
||
const HEARTBEAT_MS = 15000;
|
||
const LOG_MAX = 40;
|
||
const DNS_SD = '/usr/bin/dns-sd';
|
||
|
||
let current = null;
|
||
|
||
function emptyState(extra = {}) {
|
||
return {
|
||
listening: false,
|
||
error: '',
|
||
sessionName: '',
|
||
port: null,
|
||
advertised: false,
|
||
sources: [],
|
||
current: null,
|
||
active: [],
|
||
log: [],
|
||
mappingErrors: [],
|
||
rules: 0,
|
||
now: Date.now(),
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function endpointOf(v) {
|
||
const ep = v && typeof v === 'object' ? v : {};
|
||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||
}
|
||
|
||
module.exports = {
|
||
init({ config, log }) {
|
||
const sessionName = String(config.sessionName || '').trim() || 'ProdDash Lighting';
|
||
const port = Math.round(Number(config.sessionPort)) || 5040;
|
||
const middleC = config.middleC === 'C4' ? 'C4' : 'C3';
|
||
const exclusive = config.exclusive === true;
|
||
const headTakesOver = config.headTakesOver !== false;
|
||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||
if (mappingErrors.length) {
|
||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||
}
|
||
|
||
const senders = [
|
||
{ label: String(config.source1Name || '').trim() || 'ProPresenter', ...endpointOf(config.source1) },
|
||
{ label: String(config.source2Name || '').trim() || 'Tracks', ...endpointOf(config.source2) },
|
||
{ label: String(config.source3Name || '').trim() || 'Companion', ...endpointOf(config.source3) },
|
||
].filter((s) => s.host);
|
||
|
||
const streams = new Set();
|
||
const lists = new Map(); // lower-cased name → { name, group, state, firedAt, lastAt, lastAction, source, trigger, goes }
|
||
const history = []; // newest first
|
||
let currentKey = '';
|
||
let lastFrame = '';
|
||
let advertiser = null;
|
||
let advertised = false;
|
||
let stopped = false;
|
||
|
||
if (port < 1024 || port > 65534) throw new Error('Session port must be between 1024 and 65534');
|
||
|
||
const session = new Session({ name: sessionName, port, log });
|
||
const invites = senders.map((s) => ({ ...s, inv: null }));
|
||
|
||
/* ── what Vista was told ─────────────────────────────────────── */
|
||
|
||
function sourceLabel(p) {
|
||
return p.label || p.name || p.address;
|
||
}
|
||
|
||
function record(entry) {
|
||
history.unshift({ at: Date.now(), ...entry });
|
||
if (history.length > LOG_MAX) history.length = LOG_MAX;
|
||
}
|
||
|
||
function apply(rule, source, trigger) {
|
||
const now = Date.now();
|
||
const key = rule.list.toLowerCase();
|
||
record({ action: rule.action, list: rule.list, group: rule.group, source, trigger, mapped: true });
|
||
if (rule.action === 'flash') return; // a momentary hit: in the log, never live
|
||
if (rule.action === 'releaseAll') {
|
||
lists.clear();
|
||
currentKey = '';
|
||
return;
|
||
}
|
||
if (rule.action === 'release') {
|
||
lists.delete(key);
|
||
if (currentKey === key) currentKey = newestKey();
|
||
return;
|
||
}
|
||
let l = lists.get(key);
|
||
if (rule.action === 'go') {
|
||
// A Go replaces the other lists of its group. A Go in the first group (the
|
||
// main scene) takes over everything, as Vista's last-takes-precedence does;
|
||
// and with "Go releases others" on, every Go does.
|
||
const takeover = exclusive || (headTakesOver && rule.group && rule.group === groups[0]);
|
||
for (const [k, other] of [...lists]) {
|
||
if (k !== key && (takeover || (rule.group && other.group === rule.group))) lists.delete(k);
|
||
}
|
||
if (!l) {
|
||
l = { name: rule.list, group: rule.group, state: 'active', firedAt: now, goes: 0 };
|
||
lists.set(key, l);
|
||
}
|
||
l.state = 'active';
|
||
l.goes++;
|
||
l.lastAt = now;
|
||
l.lastAction = 'go';
|
||
l.source = source;
|
||
l.trigger = trigger;
|
||
currentKey = key;
|
||
return;
|
||
}
|
||
// pause / back act on a list that is live; on one that isn't, they only go in the log.
|
||
if (!l) return;
|
||
l.lastAt = now;
|
||
l.lastAction = rule.action;
|
||
l.source = source;
|
||
l.trigger = trigger;
|
||
if (rule.action === 'pause') l.state = 'paused'; // the next Go resumes it
|
||
if (rule.action === 'back') { l.state = 'active'; l.goes = Math.max(1, l.goes - 1); }
|
||
}
|
||
|
||
function newestKey() {
|
||
let best = '';
|
||
let at = -1;
|
||
for (const [k, l] of lists) if (l.lastAt > at) { best = k; at = l.lastAt; }
|
||
return best;
|
||
}
|
||
|
||
session.on('midi', ({ participant, event }) => {
|
||
if (event.type !== 'noteon' && event.type !== 'program') return;
|
||
const source = sourceLabel(participant);
|
||
const trigger = triggerLabel(event, middleC);
|
||
const rule = match(rules, event);
|
||
if (rule) {
|
||
apply(rule, source, trigger);
|
||
log(`${source}: ${trigger} → ${rule.action}${rule.list ? ` ${rule.list}` : ''}`);
|
||
} else {
|
||
record({ action: '', list: '', source, trigger, mapped: false });
|
||
}
|
||
broadcast();
|
||
});
|
||
session.on('change', () => broadcast());
|
||
// Senders are joined once our ports are ours — straight away normally,
|
||
// or a moment later when a busy port frees up.
|
||
session.on('listening', () => {
|
||
if (stopped) return;
|
||
log(`session "${sessionName}" listening on UDP ${port}/${port + 1}`);
|
||
for (const s of invites) s.inv = session.invite(s.host, s.port, s.label);
|
||
if (!advertiser) advertise();
|
||
});
|
||
|
||
/* ── the view-model ──────────────────────────────────────────── */
|
||
|
||
function sources() {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const s of invites) {
|
||
const inv = s.inv;
|
||
const p = inv && [...session.participants.values()].find((q) => q.connected && q.address === inv.address && q.controlPort === inv.port);
|
||
if (p) seen.add(p.key);
|
||
out.push({
|
||
label: s.label,
|
||
name: p ? p.name : '',
|
||
address: inv?.address || s.host,
|
||
configured: true,
|
||
connected: Boolean(p),
|
||
error: p ? '' : (inv?.error || ''),
|
||
lastHeard: p ? p.lastHeard : null,
|
||
});
|
||
}
|
||
for (const p of session.participants.values()) {
|
||
if (seen.has(p.key) || !p.connected) continue;
|
||
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function groupRank(l) {
|
||
const i = groups.indexOf(l.group);
|
||
return i < 0 ? groups.length : i;
|
||
}
|
||
|
||
function buildState() {
|
||
const live = [...lists.values()].sort((a, b) => b.lastAt - a.lastAt);
|
||
// The headline: the live list of the sheet's first group (the scene, say,
|
||
// rather than whichever colour changed last); without groups, the last Go.
|
||
const head = groups[0];
|
||
const cur = (head && live.find((l) => l.group === head))
|
||
|| (currentKey ? lists.get(currentKey) : null)
|
||
|| null;
|
||
return {
|
||
listening: Boolean(session.control),
|
||
error: session.error,
|
||
sessionName,
|
||
port,
|
||
advertised,
|
||
sources: sources(),
|
||
current: cur || null,
|
||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||
active: live.filter((l) => l !== cur).sort((a, b) => groupRank(a) - groupRank(b) || b.lastAt - a.lastAt),
|
||
headline: head || '',
|
||
log: history,
|
||
mappingErrors,
|
||
rules: rules.length,
|
||
now: Date.now(),
|
||
};
|
||
}
|
||
|
||
function send(payload) {
|
||
for (const res of streams) {
|
||
try { res.write(payload); } catch { streams.delete(res); }
|
||
}
|
||
}
|
||
|
||
function broadcast() {
|
||
if (stopped) return;
|
||
const state = buildState();
|
||
const { now, sources: src, ...rest } = state;
|
||
// lastHeard moves with every packet; leave it out of change detection.
|
||
const frame = JSON.stringify({ ...rest, src: src.map(({ lastHeard, ...s }) => s) });
|
||
if (frame === lastFrame) return;
|
||
lastFrame = frame;
|
||
send(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||
}
|
||
|
||
const keepalive = setInterval(() => send(': ping\n\n'), HEARTBEAT_MS);
|
||
keepalive.unref?.();
|
||
|
||
/* ── being found: Bonjour on macOS ───────────────────────────── */
|
||
|
||
// Advertising _apple-midi._udp puts us in Audio MIDI Setup's Directory on
|
||
// every Mac here, so a sender can add us with a click. macOS ships the
|
||
// dns-sd tool; we run it as a child and stop it with the module. Elsewhere
|
||
// nothing is advertised and senders are joined by address alone.
|
||
function advertise() {
|
||
if (stopped || config.advertise === false || process.platform !== 'darwin' || !fs.existsSync(DNS_SD)) return;
|
||
advertiser = spawn(DNS_SD, ['-R', sessionName, '_apple-midi._udp', 'local', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
advertiser.stdout.on('data', (d) => {
|
||
if (/Name now registered and active/i.test(String(d)) && !advertised) {
|
||
advertised = true;
|
||
log(`advertised as "${sessionName}" on port ${port}`);
|
||
broadcast();
|
||
}
|
||
});
|
||
advertiser.stderr.on('data', () => {});
|
||
advertiser.on('error', (e) => { log(`dns-sd: ${e.message}`); advertiser = null; });
|
||
advertiser.on('exit', () => {
|
||
advertiser = null;
|
||
advertised = false;
|
||
if (!stopped) setTimeout(advertise, 5000).unref?.();
|
||
});
|
||
}
|
||
|
||
/* ── start ───────────────────────────────────────────────────── */
|
||
|
||
session.start().then(() => broadcast());
|
||
|
||
current = {
|
||
streams,
|
||
getState: buildState,
|
||
clear() {
|
||
lists.clear();
|
||
currentKey = '';
|
||
record({ action: 'cleared', list: '', source: 'Dashboard', trigger: '', mapped: true });
|
||
broadcast();
|
||
},
|
||
};
|
||
|
||
return {
|
||
stop() {
|
||
stopped = true;
|
||
clearInterval(keepalive);
|
||
session.stop();
|
||
if (advertiser) { try { advertiser.kill(); } catch { /* gone */ } advertiser = null; }
|
||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||
streams.clear();
|
||
if (current && current.streams === streams) current = null;
|
||
},
|
||
health() {
|
||
if (session.error) return { status: 'error', message: session.error };
|
||
const src = sources();
|
||
const on = src.filter((s) => s.connected);
|
||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||
if (on.length) {
|
||
const off = src.filter((s) => !s.connected).map((s) => `${s.label} (${s.error || 'joining…'})`);
|
||
return {
|
||
status: off.length ? 'connecting' : 'ok',
|
||
message: `In session with ${on.map((s) => s.label).join(', ')}${off.length ? ` · waiting for ${off.join(', ')}` : ''}${map}${lines}`,
|
||
};
|
||
}
|
||
const where = advertised ? `advertised as "${sessionName}"` : `port ${port}`;
|
||
if (src.length) {
|
||
return { status: 'connecting', message: `Joining ${src.map((s) => `${s.label}${s.error ? ` (${s.error})` : ''}`).join(', ')} · ${where}${map}${lines}` };
|
||
}
|
||
return { status: 'connecting', message: `Waiting for a MIDI session to connect · ${where}${map}${lines}` };
|
||
},
|
||
};
|
||
},
|
||
|
||
/**
|
||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||
* a module with no list sits headless under whichever module precedes it
|
||
* (Slack, alphabetically). Tiles added before this keep working: they
|
||
* have no variant and use the module's schema.
|
||
*/
|
||
tiles() {
|
||
return [{
|
||
id: 'live',
|
||
name: 'Live cue lists',
|
||
description: 'The main scene Vista is running, the FX on top, and what fired them',
|
||
defaultSize: { w: 5, h: 4 },
|
||
minSize: { w: 2, h: 1 },
|
||
}];
|
||
},
|
||
|
||
routes() {
|
||
const sendJson = (res, code, body) => {
|
||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||
res.end(JSON.stringify(body));
|
||
};
|
||
return {
|
||
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
|
||
'GET /stream': (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no',
|
||
});
|
||
res.write('retry: 3000\n\n');
|
||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getState() : emptyState())}\n\n`);
|
||
if (!current) return void res.end();
|
||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||
const set = current.streams;
|
||
set.add(res);
|
||
req.on('close', () => set.delete(res));
|
||
},
|
||
'POST /clear': (req, res) => {
|
||
if (!current) return sendJson(res, 503, { error: 'Vista Cues is not running' });
|
||
current.clear();
|
||
return sendJson(res, 200, { ok: true });
|
||
},
|
||
};
|
||
},
|
||
};
|