2026-10-11 11:15:45 -04:00
'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 } ` };
},
};
},
2026-10-11 11:39:53 -04:00
/**
* 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 },
}];
},
2026-10-11 11:15:45 -04:00
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 });
},
};
},
};