From 0f0adb4c60d34b72ad089f7d1a374c2c0b3f0c36 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 6 Oct 2026 14:59:12 -0400 Subject: [PATCH 01/23] Resi Cues 1.0.1: the next cue's name keeps moving on a 30-second Resi On a Resi that publishes every ~30s, the board could start a few seconds before a publish and learn a 2s cadence from that partial first gap. Every honest 30s gap after that was thrown out as a pause, so playback read as paused 15s into every update: the countdown froze, and the next cue -- and the name on the Timers card -- stopped moving on. - The partial gap right after startup is no longer learned, and one outlier gap can no longer lock out the real cadence. - Until a cadence is measured, a pause is only called after 45s, not 15s. - The Timers card is titled with the cue's name; its status line now says "Next cue" / "Held" instead of repeating the name. - server.test.js awaited nothing, so an async test raced the next one and failed on main; tests now run one at a time. Same fix as the Resi -> ProPresenter bridge and the Companion module 0.1.2. Co-Authored-By: Claude Opus 5.5 --- modules/resi-cues/module.json | 2 +- modules/resi-cues/resi.js | 22 ++++++++++++++++++---- modules/resi-cues/resi.test.js | 26 ++++++++++++++++++++++++++ modules/resi-cues/server.js | 4 +++- modules/resi-cues/server.test.js | 29 +++++++++++++++-------------- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/modules/resi-cues/module.json b/modules/resi-cues/module.json index c97dab3..32d8348 100644 --- a/modules/resi-cues/module.json +++ b/modules/resi-cues/module.json @@ -1,7 +1,7 @@ { "id": "resi-cues", "name": "Resi Cues", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.4.0", "description": "Time until the next Resi cue point, on the board and as a timer", "provides": ["timers"], diff --git a/modules/resi-cues/resi.js b/modules/resi-cues/resi.js index 54de680..d47e7be 100644 --- a/modules/resi-cues/resi.js +++ b/modules/resi-cues/resi.js @@ -61,6 +61,8 @@ function createPlayhead(options) { let offsets = []; // { at, off } — off is mediaMs - wall time it was read let spreads = []; // how far apart honest readings sit: this link's noise let gaps = []; // how long between the playhead actually moving + let changes = 0; // readings that differed from the one before + let rejected = false; // the last gap looked like a pause and was skipped let base; // the trusted offset let lastMedia; let lastNewAt; @@ -90,15 +92,25 @@ function createPlayhead(options) { spreads = spreads.concat(-delta).slice(-20); } } - if (lastNewAt !== undefined) { + // The first reading lands at an arbitrary point in Resi's cycle, so the gap + // from it to the next one is only a fraction of the real cadence. Learning + // from it locked in a 2s cadence that rejected every honest 30s gap. + if (lastNewAt !== undefined && changes > 0) { const gap = t - lastNewAt; // The gap either side of a pause is not Resi's cadence — recording it // would inflate the estimate and make the next pause slow to notice. + // But a pause is one gap; twice in a row is the cadence having changed. const worst = gaps.length ? Math.max.apply(null, gaps) : 0; - if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) { - gaps = gaps.concat(gap).slice(-8); + if (gap > 0 && gap < 60000) { + if (!worst || gap <= worst * 3 || rejected) { + gaps = gaps.concat(gap).slice(-8); + rejected = false; + } else { + rejected = true; + } } } + if (lastNewAt !== undefined) changes++; offsets = offsets.concat({ at: t, off }); lastMedia = mediaMs; lastNewAt = t; @@ -118,7 +130,9 @@ function createPlayhead(options) { // Only call it paused once the playhead has been still for clearly longer than // Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a // countdown that freezes mid-service is the worse failure of the two. - const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)); + // Until a full gap has been measured the cadence is unknown; assume the slowest + // Resi plausibly is, or a 30s publisher reads as paused 15s after every start. + const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000; const playing = base !== undefined && t - lastNewAt < pauseAfter; if (!playing) { return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs }; diff --git a/modules/resi-cues/resi.test.js b/modules/resi-cues/resi.test.js index dd11352..6022e5b 100644 --- a/modules/resi-cues/resi.test.js +++ b/modules/resi-cues/resi.test.js @@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => { assert.strictEqual(surroundingCues([], 1000).next, null); }); +/* ── starting partway through Resi's cycle ─────────────────────────────── */ + +// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a +// few seconds before a publish. The first "gap" it measured was 2s (9s after a +// reboot), and once that was the cadence every honest 30s gap was thrown out as a +// pause gap -- so playback read as paused 15s into every update, the countdown froze, +// and the next cue (and its name) stopped moving on. +for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) { + test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => { + const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 }); + w.lastPublish = w.t - into; + const head = createPlayhead({ now: () => w.t }); + const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); }; + let paused = 0; + for (let i = 0; i < 100; i++) if (!second().playing) paused++; + if (burst) { + w.media += 15 * 60000; // a seek, published at once... + w.lastPublish = w.t - w.nextCadence; + second(); second(); + w.lastPublish = w.t - w.nextCadence; // ...and again 2s later + } + for (let i = 0; i < 300; i++) if (!second().playing) paused++; + assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`); + }); +} + /* ── the matrix ────────────────────────────────────────────────────────── */ const argv = process.argv.slice(2).map(Number); diff --git a/modules/resi-cues/server.js b/modules/resi-cues/server.js index 0fad3ef..3fe385b 100644 --- a/modules/resi-cues/server.js +++ b/modules/resi-cues/server.js @@ -146,7 +146,9 @@ module.exports = { elapsedMs: null, remainingMs, targetMs: Math.max(1000, state.next.ms - from), - status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' }, + // The label already is the cue's name, and it is what the card is titled + // with; repeating it on the status line only crowded the card. + status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' }, detail: state.event || '', }); } diff --git a/modules/resi-cues/server.test.js b/modules/resi-cues/server.test.js index 40a2100..ddb43a3 100644 --- a/modules/resi-cues/server.test.js +++ b/modules/resi-cues/server.test.js @@ -18,8 +18,9 @@ const assert = require('assert'); let passed = 0; -function test(name, fn) { - fn(); +// Awaited, so an async test finishes before the next one changes the world under it. +async function test(name, fn) { + await fn(); passed++; console.log(` ok ${name}`); } @@ -101,7 +102,7 @@ function collect(route, req) { const routes = server.routes({ config: {}, log: (m) => logged.push(m) }); - test('signs in once and finds the configured decoder', () => { + await test('signs in once and finds the configured decoder', () => { assert.strictEqual(world.logins, 1, 'should sign in exactly once'); const state = handle.state; assert.strictEqual(state.status, 'ok', state.message); @@ -110,20 +111,20 @@ function collect(route, req) { assert.strictEqual(state.cues.length, 3); }); - test('works out the cue either side of the playhead', () => { + await test('works out the cue either side of the playhead', () => { const state = handle.state; assert.strictEqual(state.next.name, 'Campus Host'); assert.strictEqual(state.prev.name, 'Welcome'); assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`); }); - test('/state answers the same snapshot as the stream', () => { + await test('/state answers the same snapshot as the stream', () => { const res = collect(routes['GET /state']); assert.strictEqual(res.code, 200); assert.deepStrictEqual(JSON.parse(res.body), handle.state); }); - test('/stream opens with an event so a new tile paints at once', () => { + await test('/stream opens with an event so a new tile paints at once', () => { const res = collect(routes['GET /stream']); assert.strictEqual(res.code, 200); assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8'); @@ -131,7 +132,7 @@ function collect(route, req) { assert.ok(res.body.includes('event: state'), 'should send an initial state event'); }); - test('/timers offers the countdown and the playhead, shaped as the guide says', () => { + await test('/timers offers the countdown and the playhead, shaped as the guide says', () => { const snap = JSON.parse(collect(routes['GET /timers']).body); assert.strictEqual(snap.source, 'resi-cues'); assert.strictEqual(snap.label, 'Resi'); @@ -145,7 +146,7 @@ function collect(route, req) { assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`); // the countdown runs from the cue before it, so a consumer can colour progress assert.strictEqual(next.targetMs, 450000); - assert.strictEqual(next.status.text, 'Campus Host'); + assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past'); assert.strictEqual(playhead.id, 'playhead'); @@ -154,7 +155,7 @@ function collect(route, req) { assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000); }); - test('a cue fired at the host moves the countdown on the next poll', async () => { + await test('a cue fired at the host moves the countdown on the next poll', async () => { world.position = '00:12:40.000'; // jumped past Campus Host tickFn(); await settle(); @@ -162,10 +163,10 @@ function collect(route, req) { assert.strictEqual(state.next.name, 'Message'); assert.strictEqual(state.prev.name, 'Campus Host'); const snap = JSON.parse(collect(routes['GET /timers']).body); - assert.strictEqual(snap.timers[0].label, 'Message'); + assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue }); - test('a decoder with no event reports nothing playing rather than stale cues', async () => { + await test('a decoder with no event reports nothing playing rather than stale cues', async () => { world.eventId = ''; tickFn(); await settle(); @@ -174,13 +175,13 @@ function collect(route, req) { assert.strictEqual(handle.state.event, ''); }); - test('health speaks about the upstream, not about itself', () => { + await test('health speaks about the upstream, not about itself', () => { const h = handle.health(); assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status); assert.ok(h.message.length > 0, 'health should say something useful'); }); - test('an unconfigured module idles instead of hammering Resi', () => { + await test('an unconfigured module idles instead of hammering Resi', () => { const before = world.logins; const idle = server.init({ config: {}, log: () => {} }); assert.strictEqual(world.logins, before, 'must not sign in with no credentials'); @@ -189,7 +190,7 @@ function collect(route, req) { idle.stop(); }); - test('stop() leaves nothing running', () => { + await test('stop() leaves nothing running', () => { handle.stop(); const res = collect(routes['GET /state']); assert.strictEqual(JSON.parse(res.body).status, 'error'); From 67f612e371bac4301331f03c910ec76f987f7c17 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 6 Oct 2026 15:44:14 -0400 Subject: [PATCH 02/23] Resi Cues 1.0.2: a failed Resi login holds off instead of retrying every second A failed login left no session, so the next 1s poll signed in again, and the next, for ever. Resi answers that with 429s, and since the retries never let up the account never got out of them: at North Attleboro the decoder picker stayed empty with "Resi login failed (429)". The picker made it worse by building a fresh client -- and signing in -- on every admin page load. - After a failed login, further attempts wait 15s, doubling to 5min (or as long as Resi's Retry-After asks); calls in between fail at once with the reason and how long until the next try. - The status says what happened: rate-limited, password rejected, or Resi unreachable. - The decoder picker reuses the running module's client when the login matches, otherwise one client per login, so it shares the hold-off. Co-Authored-By: Claude Opus 5.5 --- modules/resi-cues/module.json | 2 +- modules/resi-cues/resi.js | 51 ++++++++++++++++++++++++++++---- modules/resi-cues/server.js | 20 +++++++++++-- modules/resi-cues/server.test.js | 47 +++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/modules/resi-cues/module.json b/modules/resi-cues/module.json index 32d8348..6b507ab 100644 --- a/modules/resi-cues/module.json +++ b/modules/resi-cues/module.json @@ -1,7 +1,7 @@ { "id": "resi-cues", "name": "Resi Cues", - "version": "1.0.1", + "version": "1.0.2", "proddash": ">=1.4.0", "description": "Time until the next Resi cue point, on the board and as a timer", "provides": ["timers"], diff --git a/modules/resi-cues/resi.js b/modules/resi-cues/resi.js index d47e7be..4b5e861 100644 --- a/modules/resi-cues/resi.js +++ b/modules/resi-cues/resi.js @@ -39,6 +39,8 @@ function msToPosition(total) { const WINDOW_MS = 120000; // how long a reading stays useful const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek +const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login +const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts /** * Resi republishes a decoder's `position` only every few seconds, on an irregular @@ -175,13 +177,43 @@ function createResiClient(options) { return h; } + /* A failed login used to be retried on the very next poll -- once a second, for + ever. Resi answers that with 429s, and since the retries never let up, the + account never got out of them. So a failure now holds off further attempts, + doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every + call in between fails at once with the same reason instead of signing in. */ + const now = typeof opts.now === 'function' ? opts.now : () => Date.now(); + let loginFailures = 0; + let loginBlockedUntil = 0; + let loginError = ''; + async function login() { - const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ userName: opts.username, password: opts.password }), - }); - if (!res.ok) throw new Error(`Resi login failed (${res.status})`); + if (now() < loginBlockedUntil) { + const wait = Math.ceil((loginBlockedUntil - now()) / 1000); + throw new Error(`${loginError} — retrying in ${wait}s`); + } + let res; + try { + res = await doFetch(`${RESI}/api/v3/login?newToken=true`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ userName: opts.username, password: opts.password }), + }); + } catch (err) { + holdOff(`Resi unreachable (${(err && err.message) || err})`, 0); + throw new Error(loginError); + } + if (!res.ok) { + const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)' + : res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password` + : `Resi login failed (${res.status})`; + const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0; + holdOff(reason, retryAfter * 1000); + throw new Error(reason); + } + loginFailures = 0; + loginBlockedUntil = 0; + loginError = ''; const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie()) || (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); const jar = {}; @@ -200,6 +232,13 @@ function createResiClient(options) { return me; } + function holdOff(reason, askedMs) { + loginFailures++; + const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1)); + loginBlockedUntil = now() + Math.max(backoff, askedMs || 0); + loginError = reason; + } + async function ensureSession() { if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login(); } diff --git a/modules/resi-cues/server.js b/modules/resi-cues/server.js index 3fe385b..e981b52 100644 --- a/modules/resi-cues/server.js +++ b/modules/resi-cues/server.js @@ -70,6 +70,7 @@ function emptyTimers(now) { } let current = null; // the live handle, shared with routes() +let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials module.exports = { init({ config, log }) { @@ -273,6 +274,7 @@ module.exports = { stateStreams, timerStreams, resi, + credentialsKey: `${username}\n${password}`, configured: Boolean(username && password), stop() { stopped = true; @@ -334,9 +336,21 @@ module.exports = { return json(res, 200, { options: [], note: 'Enter the Resi login first' }); } try { - const client = createResiClient({ - username: String(config.username), password: String(config.password), - }); + // The running module's client when it has these credentials, else one kept + // per login: a fresh client per request signed in on every admin page load, + // and its own hold-off never applied -- which kept Resi rate-limiting. + const username = String(config.username).trim(); + const key = `${username}\n${config.password}`; + let client; + if (current && current.resi && current.credentialsKey === key) client = current.resi; + else { + if (!pickerClient || pickerClient.key !== key) { + pickerClient = { key, client: createResiClient({ + username, password: String(config.password), + }) }; + } + client = pickerClient.client; + } const options = []; for (const v of await client.venues()) { for (const p of await client.players(v.uuid)) { diff --git a/modules/resi-cues/server.test.js b/modules/resi-cues/server.test.js index ddb43a3..9d50d6c 100644 --- a/modules/resi-cues/server.test.js +++ b/modules/resi-cues/server.test.js @@ -196,6 +196,53 @@ function collect(route, req) { assert.strictEqual(JSON.parse(res.body).status, 'error'); }); + // Seen live at North Attleboro: a failed login was retried on every 1s poll, so + // Resi answered 429 and kept answering it, and the decoder picker stayed empty. + await test('a failed login holds off instead of retrying every poll', async () => { + const { createResiClient } = require('./resi'); + let t = 1700000000000; + let logins = 0; + let limited = true; + const client = createResiClient({ + username: 'a@b.c', + password: 'secret', + now: () => t, + fetch: async (url) => { + const path = String(url).replace('https://central.resi.io', '').split('?')[0]; + if (path === '/api/v3/login') { + logins++; + if (limited) return reply({}, { ok: false, status: 429 }); + return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } }); + } + if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' }); + if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]); + return reply({}, { ok: false, status: 404 }); + }, + }); + const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } }; + + assert.match(await poll(), /rate-limiting/); + for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls + assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`); + assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden'); + + limited = false; + t += 5 * 60000; // past the longest hold-off + assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in'); + const after = logins; + for (let i = 0; i < 30; i++) { t += 1000; await poll(); } + assert.strictEqual(logins, after, 'a good session is not signed in again'); + }); + + await test('a rejected password says so', async () => { + const { createResiClient } = require('./resi'); + const client = createResiClient({ + username: 'a@b.c', password: 'wrong', now: () => 0, + fetch: async () => reply({}, { ok: false, status: 400 }), + }); + await assert.rejects(client.venues(), /check the password/); + }); + globalThis.setInterval = realSetInterval; globalThis.clearInterval = realClearInterval; console.log(`\n${passed} tests passed`); From 79d32373284b1418e5fe13dda73c22f52b871ad4 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 6 Oct 2026 16:27:38 -0400 Subject: [PATCH 03/23] Resi Cues 1.0.3: a login without access to the venue stops signing in every poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 401/403 was answered by signing in again on the spot. For a login that works but may not list the account's venues (media@waters.church at North Attleboro got 403 on /venues), that was a successful sign-in on every 1s poll -- the pattern that had just got another Resi account locked. - A refusal straight after signing in is a permission, not an expired session: it is held off like a failed login, with the message "This Resi login isn't allowed to see its venues (403) — ask a Resi admin for access". Only a session over a minute old is renewed. - 429 on any request holds off too, not only on the login. - The players cache-bust fallback no longer drops the parameter for good on a 401/403/429, which are about the login rather than the parameter. Co-Authored-By: Claude Opus 5.5 --- modules/resi-cues/module.json | 2 +- modules/resi-cues/resi.js | 38 +++++++++++++++++++++++++------- modules/resi-cues/server.test.js | 31 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/modules/resi-cues/module.json b/modules/resi-cues/module.json index 6b507ab..e930cc0 100644 --- a/modules/resi-cues/module.json +++ b/modules/resi-cues/module.json @@ -1,7 +1,7 @@ { "id": "resi-cues", "name": "Resi Cues", - "version": "1.0.2", + "version": "1.0.3", "proddash": ">=1.4.0", "description": "Time until the next Resi cue point, on the board and as a timer", "provides": ["timers"], diff --git a/modules/resi-cues/resi.js b/modules/resi-cues/resi.js index 4b5e861..8692b37 100644 --- a/modules/resi-cues/resi.js +++ b/modules/resi-cues/resi.js @@ -41,6 +41,7 @@ const WINDOW_MS = 120000; // how long a reading stays useful const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts +const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session /** * Resi republishes a decoder's `position` only every few seconds, on an irregular @@ -225,7 +226,7 @@ function createResiClient(options) { token = jar.livingas1 || ''; if (!token) throw new Error('Resi login returned no session cookie'); cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; '); - loggedInAt = Date.now(); + loggedInAt = now(); const me = await get('/api_v2.svc/users/me'); customerId = (me && me.customerId) || ''; if (!customerId) throw new Error('This Resi account has no customerId'); @@ -240,16 +241,34 @@ function createResiClient(options) { } async function ensureSession() { - if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login(); + if (!token || now() - loggedInAt > 50 * 60 * 1000) await login(); } + /* The same hold-off covers every request, not just the login. A 401/403 used to + sign in again on the spot, every time: for a login that simply isn't allowed to + see a venue, that was a fresh sign-in on every poll. Now a refusal straight + after signing in is taken as what it is -- a permission -- and held off like a + failed login; only an older session is renewed. A 429 holds off too. */ async function get(path) { - const res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); - if (res.status === 401 || res.status === 403) { + if (now() < loginBlockedUntil) { + const wait = Math.ceil((loginBlockedUntil - now()) / 1000); + throw new Error(`${loginError} — retrying in ${wait}s`); + } + let res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); + if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) { await login(); - const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() }); - if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`); - return retry.json(); + res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); + } + if (res.status === 401 || res.status === 403) { + const where = path.includes('/venues') ? 'its venues' + : path.includes('/players') ? "this venue's decoders" : 'part of the account'; + holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0); + throw new Error(loginError); + } + if (res.status === 429) { + const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0; + holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000); + throw new Error(loginError); } if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`); return res.json(); @@ -269,7 +288,10 @@ function createResiClient(options) { try { return (await get(`${path}?_=${Date.now()}`)) || []; } catch (err) { - if (!/\((4\d\d)\)/.test(String(err.message))) throw err; + // Only an objection to the parameter itself; a refusal or a "slow down" + // (401/403/429) is about the login, and dropping it then lost fresh readings. + const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1]; + if (!code || ['401', '403', '429'].includes(code)) throw err; cacheBust = false; } } diff --git a/modules/resi-cues/server.test.js b/modules/resi-cues/server.test.js index 9d50d6c..a9a83ba 100644 --- a/modules/resi-cues/server.test.js +++ b/modules/resi-cues/server.test.js @@ -243,6 +243,37 @@ function collect(route, req) { await assert.rejects(client.venues(), /check the password/); }); + // Seen live: media@waters.church signs in fine but may not list venues (403). The + // client answered every 403 by signing in again -- a fresh login on every poll. + await test('a login that may not see venues is held off, not signed in every poll', async () => { + const { createResiClient } = require('./resi'); + let t = 1700000000000; + let logins = 0; + let venueCalls = 0; + const client = createResiClient({ + username: 'a@b.c', password: 'secret', now: () => t, + fetch: async (url) => { + const path = String(url).replace('https://central.resi.io', '').split('?')[0]; + if (path === '/api/v3/login') { + logins++; + return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } }); + } + if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' }); + if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); } + return reply({}, { ok: false, status: 404 }); + }, + }); + const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } }; + + assert.match(await poll(), /isn't allowed to see its venues \(403\)/); + for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls + // At most one renewal once the session is over a minute old (in case it really + // had expired), then the hold-off keeps growing -- not one sign-in per poll. + assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`); + assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`); + assert.match(await poll(), /ask a Resi admin/); + }); + globalThis.setInterval = realSetInterval; globalThis.clearInterval = realClearInterval; console.log(`\n${passed} tests passed`); From 7481d1d9e2ff9b54071cc2209c1218efc6283ff4 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 7 Oct 2026 11:46:50 -0400 Subject: [PATCH 04/23] =?UTF-8?q?Shell=201.13.0:=20the=20Layouts=20menu=20?= =?UTF-8?q?lists=20the=20tiles=20a=20layout=20holds=20but=20the=20board=20?= =?UTF-8?q?is=20not=20showing,=20with=20a=20=E2=9C=95=20to=20remove=20each?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tile whose module stopped offering its picker entry — Slack's Send message and Quick replies since the Send Message module took them over — kept its place in the layout and drew nothing, as designed, but still held its grid cells with no control anywhere to let them go: a dead area the operator could not place anything in. The Layouts menu now has a Not shown section, present only while the layout holds such a tile. One row per tile: its title (or the module's name plus the variant, or the module id when it is not installed), why in a few words — "not offered right now", "module disabled", "module needs a newer ProdDash", "module not installed" — and a drawn ✕ with a tooltip that removes it, saves the layout and frees the cells, leaving the menu open as the Grid Size stepper and Alerts do. The section repaints while the menu is open whenever tiles become offered or stop being, and only when the list changed. A tile that merely waits for its module keeps its place as before. GET /api/modules also returns `inactive` — the installed modules that are not running, id, name and reason only — so a dashboard can tell a disabled module from one that is not installed. The toast after loading a named layout now says the Layouts menu can remove the tiles it did not show. README and the module guide updated. Co-Authored-By: Claude Fable 5.1 --- README.md | 13 ++++-- docs/MODULE-GUIDE.md | 10 +++-- package.json | 2 +- public/shell.js | 97 ++++++++++++++++++++++++++++++++++++++++++-- public/style.css | 27 ++++++++++++ server.js | 11 ++++- 6 files changed, 147 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2fe2a0f..1b85fa4 100644 --- a/README.md +++ b/README.md @@ -316,10 +316,15 @@ below. Nothing is installed without someone clicking. name, load a named layout (it becomes a local copy — browsers are never linked), or reset this browser's dashboard. Loading says what it did — *Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right - now* — with **Undo** in the message to get back the arrangement you had. - A tile whose module or picker entry isn't offered at the moment keeps its - place and shows nothing, then appears when it is; it is never replaced by - the module's default tile. Saving over an existing name asks first, and + now; the Layouts menu can remove them* — with **Undo** in the message to + get back the arrangement you had. A tile whose module or picker entry + isn't offered at the moment keeps its place and shows nothing, then + appears when it is; it is never replaced by the module's default tile. + While any tile is in that state the same menu has a **Not shown** section + listing each one with why — *not offered right now*, *module disabled*, + *module not installed* — and a ✕ that removes it and frees its cells, for + the tile that is not coming back (a module that stopped offering that + kind of tile). Saving over an existing name asks first, and the server keeps the copy it replaced: **Restore previous** in Admin → Saved layouts swaps it back (and swapping again undoes that). The menu's **Grid Size** stepper sets how many columns this browser cuts the board into — diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 1d53ab3..9edee5c 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -688,8 +688,11 @@ module.exports = { tile added from an entry only when that entry (or one of its `aliases`) is in your list right now; otherwise the tile keeps its place in the layout, draws nothing, and appears the moment your list offers it again — a - Service timer waits for PCO Plan, a transcript for its channel. So your - client is never asked to render an entry you did not offer at mount time. + Service timer waits for PCO Plan, a transcript for its channel. The + operator can remove such a tile from the Layouts menu's **Not shown** + section, so an entry you retire for good (a send box that moved to another + module) leaves no dead cells behind. So your client is never asked to + render an entry you did not offer at mount time. One case is still yours: an entry that vanishes *after* the tile mounted (a timer deleted in ProPresenter mid-service). Render nothing for it — no placeholder, no error — and the shell folds the tile away at its next @@ -1044,7 +1047,8 @@ client-side failures (a tile that fails to start says so in the tile). **Poke the pieces directly:** -- `GET /api/modules` — what the shell sees (your manifest + non-secret config). +- `GET /api/modules` — what the shell sees (your manifest + non-secret + config; `inactive` names the installed modules that are not running). - `GET /api/modules//...` — your routes, `curl`-able. For SSE: `curl -N http://localhost:24500/api/modules//stream`. - `GET /api/admin/state` — enabled flags, health, mount errors. diff --git a/package.json b/package.json index 2dd686c..e0ff2e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.12.0", + "version": "1.13.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/shell.js b/public/shell.js index 261f7db..e9be457 100644 --- a/public/shell.js +++ b/public/shell.js @@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false, /* ── module registry ────────────────────────────────────────────────── */ let registry = new Map(); // module id -> manifest from GET /api/modules +/* The installed modules that are not running — disabled, or needing a newer + shell — id -> { id, name, reason }, from the same GET. Read for one thing: + to say why a tile one of them left in the layout is not drawn. */ +let inactiveModules = new Map(); /** fetch that gives up after `ms`. A request the browser has queued behind held connections would otherwise pend forever, and so would anything @@ -208,6 +212,7 @@ async function loadRegistry() { if (!res.ok) throw new Error('modules ' + res.status); const body = await res.json(); registry = new Map((body.modules || []).map((m) => [m.id, m])); + inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m])); return true; } catch { return false; @@ -487,7 +492,9 @@ const GEAR_SVG = ' !tileEls.has(t.id)).map((tile) => { + const man = registry.get(tile.module); + if (man) { + const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name); + return { tile, title, reason: 'not offered right now' }; + } + const off = inactiveModules.get(tile.module); + if (off) { + const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled'; + return { tile, title: tile.title || off.name || tile.module, reason }; + } + return { tile, title: tile.title || tile.module, reason: 'module not installed' }; + }); +} + /** The tiles a layout holds that the board is not showing, by name. */ function unshownTileNames() { - return tiles.filter((t) => !tileEls.has(t.id)) - .map((t) => t.title || registry.get(t.module)?.name || t.module); + return unshownTiles().map((u) => u.title); +} + +/* Repaints the Layouts menu's Not shown section while the menu is open — + set by unshownSection(), called whenever tiles become offered or stop + being, so the list never lags the board behind it. */ +let repaintUnshown = () => {}; + +/** The Layouts menu's Not shown section: one row per tile the layout holds + that the board is not drawing — its name, why in a word or two, and a ✕ + that removes it — present only while there is one. A tile that waits for + its module (a Service timer before PCO Plan is up) is listed but can stay; + this exists for the one that will never be back, which would otherwise + hold its cells with no control anywhere to let them go. */ +function unshownSection() { + const wrap = document.createElement('div'); + wrap.className = 'menu-unshown'; + const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n'); + let painted = ''; + const paint = () => { + wrap.innerHTML = ''; + const list = unshownTiles(); + painted = signature(list); + wrap.hidden = !list.length; + if (!list.length) return; + const divider = document.createElement('div'); + divider.className = 'menu-divider'; + const title = document.createElement('div'); + title.className = 'menu-title'; + title.textContent = 'Not shown'; + wrap.append(divider, title); + for (const { tile, title: name, reason } of list) { + const row = document.createElement('div'); + row.className = 'menu-item menu-unshown-row'; + const label = document.createElement('span'); + label.textContent = name; + const sub = document.createElement('span'); + sub.className = 'sub'; + sub.textContent = reason; + label.appendChild(sub); + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'menu-x'; + remove.setAttribute('data-tip', 'Remove'); + remove.setAttribute('aria-label', `Remove ${name}`); + remove.innerHTML = CLOSE_SVG; + remove.addEventListener('click', (e) => { + // The row goes with the tile, so the click must not reach the + // document as one from outside the menu — the menu stays open + // to show the next row (or none), as the stepper and Alerts do. + e.stopPropagation(); + removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does + paint(); + }); + row.append(label, remove); + wrap.appendChild(row); + } + }; + paint(); + // Only when something changed: the registry is re-read every few seconds + // while a tile is folded away, and rows rebuilt under the pointer for + // nothing would lose the tooltip (and the click) the operator was on. + repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); }; + return wrap; } function buildTile(tile) { @@ -2253,6 +2341,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { // how one is arranged. menu.appendChild(gridStepper()); menu.appendChild(alertsFold()); + menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn const stepperDivider = document.createElement('div'); stepperDivider.className = 'menu-divider'; menu.appendChild(stepperDivider); @@ -2344,7 +2433,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : ''; const note = unshown.length - ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now` + ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}` : ''; toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, { ms: unshown.length ? 10000 : 7000, diff --git a/public/style.css b/public/style.css index af2634f..5b27d5c 100644 --- a/public/style.css +++ b/public/style.css @@ -368,6 +368,33 @@ body[data-alert="critical"] .tile.is-tinted { text-transform: uppercase; } +/* the Layouts menu's Not shown rows: a tile the layout holds but the board + is not drawing, why in a word or two, and the ✕ that removes it */ +.menu-unshown[hidden] { display: none; } +.menu-unshown-row { cursor: default; } +.menu-unshown-row:hover { background: none; } +.menu-unshown-row > span:first-child { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.menu-x { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: none; + border: none; + border-radius: 5px; + color: var(--muted); + cursor: pointer; +} +.menu-x:hover { background: var(--border); color: var(--danger); } +.menu-x svg { width: 12px; height: 12px; display: block; } + /* ── grid ──────────────────────────────────────────────────────────── */ #gridwrap { diff --git a/server.js b/server.js index cb8a838..7fc0f8e 100644 --- a/server.js +++ b/server.js @@ -2336,8 +2336,17 @@ function handleRequest(req, res) { /* — shell API — */ if (urlPath === '/api/modules' && req.method === 'GET') { + // Beside what dashboards may load: the installed modules that are not + // running, by id and name only, so a tile one of them left in a layout + // can say why it is not drawn ("module disabled") instead of guessing at + // "not installed". Nothing of their config travels with it. + const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({ + id, + name: String(manifests.get(id).name || id), + reason: isEnabled(id) ? 'incompatible' : 'disabled', + })); Promise.all([...manifests.keys()].filter(isActive).map(clientManifest)) - .then((list) => sendJson(res, 200, { modules: list })) + .then((list) => sendJson(res, 200, { modules: list, inactive })) .catch((err) => { console.error('[proddash] /api/modules failed:', err); if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); From 6b51044be63e838324b6e6e7d34e1c4497576894 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 7 Oct 2026 14:00:37 -0400 Subject: [PATCH 05/23] Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new module that does what Overflow's "ProPresenter Integration" helper app does, inside ProdDash: the live slide's notes are read from ProPresenter (GET /v1/status/slide, twice a second) and when they carry one of the admin page's keywords, the chosen Overflow Tap groups are pointed at that keyword's destination. One tile: the keyword Tap is on (and since when), its destination and the groups moved, a chip saying whether the slide on screen carries a keyword, and the last four switches. A failed switch turns the tile red with the reason, retries three times, and can flash an alert channel. Admin config: ProPresenter endpoint + password; Overflow client ID + API key (x-client-id / x-api-key, the public API's two headers); Tap groups to switch (multiselect read from the account over /groups); keywords as text, one per line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode (whole word / anywhere / own line); alerts switch + channel. The one unverified piece: Overflow's public API documents only reads for Tap (OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new destinationId — the natural shape for this API — and every switch is confirmed by reading the group back, so a refusal shows on the tile instead of passing silently. overflow.js is the one place to change if Overflow names a different route. Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 1 + README.md | 28 +- modules/overflow-tap/client.js | 231 +++++++++ modules/overflow-tap/keywords.js | 101 ++++ modules/overflow-tap/keywords.selftest.js | 115 +++++ modules/overflow-tap/module.json | 144 ++++++ modules/overflow-tap/overflow.js | 126 +++++ modules/overflow-tap/server.js | 552 ++++++++++++++++++++++ modules/overflow-tap/style.css | 213 +++++++++ tools/overflow-tap-mock.js | 183 +++++++ 10 files changed, 1692 insertions(+), 2 deletions(-) create mode 100644 modules/overflow-tap/client.js create mode 100644 modules/overflow-tap/keywords.js create mode 100644 modules/overflow-tap/keywords.selftest.js create mode 100644 modules/overflow-tap/module.json create mode 100644 modules/overflow-tap/overflow.js create mode 100644 modules/overflow-tap/server.js create mode 100644 modules/overflow-tap/style.css create mode 100644 tools/overflow-tap-mock.js diff --git a/CLAUDE.md b/CLAUDE.md index 572c2c3..a21ee7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ title-bar controls). The rules that matter most: `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). `node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`). + `node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Tap API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; `--read-only` fails every switch). ## Reference code (read-only forks — don't develop these here) diff --git a/README.md b/README.md index 2fe2a0f..47e18b1 100644 --- a/README.md +++ b/README.md @@ -510,6 +510,26 @@ below. Nothing is installed without someone clicking. read from the account), and a folded **Timing** group for the poll intervals and a position trim. Tile settings: cue name, previous cue, playhead, cue list. +- **Overflow Tap** — what Overflow's own ProPresenter helper app does, + inside ProdDash: the live slide's notes are read from ProPresenter, and + when they carry one of the admin page's keywords, the chosen Overflow Tap + groups are pointed at that keyword's destination — so a tap on a disc + follows what is on screen. A slide with no keyword changes nothing. The + tile is one keyword, big: what Tap is on (and since when), its destination + and the groups moved, a chip saying whether the slide on screen carries a + keyword, and the last four switches with their times; a failed switch + turns the tile red with the reason and, if switched on in admin, flashes + an alert channel. Admin config: the ProPresenter endpoint, the Overflow + API credentials (Developers › API Keys in the Overflow Dashboard), the Tap + groups to switch (read from the account), the keywords — one per line as + `give = Give Online`, optionally `@ Lobby, Kids Wing` to switch only + those groups — and how a keyword matches (whole word, anywhere, or on its + own line). Tile settings are four switches: destination, groups, slide + match, history. **One thing is still unverified:** Overflow's public API + documents only *reads* for Tap, so the call that moves a group (`PATCH + /api/v3/tap/groups/{id}` with the new `destinationId`) is the natural + shape, not a documented one; every switch is confirmed by reading the + group back, so a refusal shows on the tile rather than passing silently. - **Band Lineup** — the backstage stage board: everyone on stage in their mic slot with photo, name and mic/pack label, on one shared board that every screen sees the same way — whoever edits it changes it everywhere, over SSE. @@ -560,8 +580,12 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js` run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and `node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup board — a plan, its team members, birthdays and photos — run ProdDash with -`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of -the [module guide](docs/MODULE-GUIDE.md). +`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and +`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry +keywords on `127.0.0.1:1602` plus the Overflow Tap API on `127.0.0.1:24720` +— run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; +`--read-only` makes the switch fail the way the public API does today). See +the testing section of the [module guide](docs/MODULE-GUIDE.md). ## Install as a desktop web app diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js new file mode 100644 index 0000000..29f3a46 --- /dev/null +++ b/modules/overflow-tap/client.js @@ -0,0 +1,231 @@ +/* Overflow Tap — the tile. + * + * One fact matters across a booth: which keyword Tap is on right now, and + * whether the switch actually landed. That is the big line. Under it, the + * destination it maps to and the groups it moved; a small line says whether + * the slide on screen carries a keyword at all; and the last four switches + * sit at the bottom with their times, so a tech can see the service's trail. + * + * Everything comes from the server's `state` stream. The tile never talks to + * ProPresenter or Overflow itself. + */ + +const HISTORY_SHOWN = 4; + +const ICON_REFRESH = ''; + +export default function create({ root, moduleApi }) { + let state = null; + let stream = null; + let refreshBtn = null; + let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state + + const settings = () => moduleApi.instanceSettings || {}; + const on = (key) => settings()[key] !== false; + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + function clock(ms) { + if (!ms) return ''; + try { + return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } + } + + /** The one-line reason the tile cannot do its job, or '' when it can. */ + function blocker() { + if (!state) return 'Waiting for ProdDash'; + const pp = state.propresenter || {}; + const of = state.overflow || {}; + if (!pp.configured) return 'No ProPresenter host — set one in /admin'; + if (!of.configured) return 'No Overflow API key — add it in /admin'; + if (!(state.keywords || []).length) return 'No keywords — add them in /admin'; + return ''; + } + + function render() { + root.textContent = ''; + const box = el('div', 'ot-root'); + root.append(box); + + const stop = blocker(); + if (stop) { + box.append(el('div', 'ot-empty', stop)); + return; + } + + const pp = state.propresenter || {}; + const of = state.overflow || {}; + const cur = state.current; + const offline = streamDown || !pp.reachable || !of.reachable; + + if (offline) { + box.classList.add('is-offline'); + const why = streamDown ? 'ProdDash connection lost' + : !of.reachable ? (of.error || 'Overflow unreachable') + : (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable'); + box.append(el('div', 'ot-offline', why)); + } + + const main = el('div', 'ot-main'); + if (cur) { + // The label carries the state and, when there is one, the moment it + // became true; the small lines under the destination are the groups + // moved — or, when the switch failed or was undone in Overflow, what + // went wrong, in their place (a tile has room for one or the other). + let label = `TAP IS ON · since ${clock(cur.at)}`; + let tone = 'ok'; + let note = ''; + let noteClass = 'ot-error'; + if (cur.pending) { + label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; + tone = 'muted'; + } else if (!cur.ok) { + label = `NOT SWITCHED · ${clock(cur.at)}`; + tone = 'alert'; + note = cur.error || ''; + } else if (cur.drift) { + label = `TAP WAS ON · since ${clock(cur.at)}`; + tone = 'warn'; + note = cur.drift; + noteClass = 'ot-error is-warn'; + } + main.dataset.tone = tone; + main.append(el('div', 'ot-label', label)); + main.append(el('div', 'ot-keyword', cur.keyword)); + if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : '')); + if (note) main.append(el('div', noteClass, note)); + else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); + } else { + main.dataset.tone = 'muted'; + main.append(el('div', 'ot-label', 'NO KEYWORD YET')); + main.append(el('div', 'ot-keyword is-none', '—')); + main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it')); + } + box.append(main); + + if (on('showSlide')) { + const slide = state.slide || {}; + let text; + let cls = 'ot-slide'; + if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; } + else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; } + else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; } + else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; } + else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; } + box.append(el('div', cls, text)); + } + + if (on('showHistory')) { + const rows = (state.history || []).slice(0, HISTORY_SHOWN); + const list = el('ol', 'ot-history'); + if (!rows.length) { + list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet')); + } + for (const h of rows) { + const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`); + row.title = h.ok + ? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}` + : `${h.keyword}: ${h.error || 'failed'}`; + row.append(el('span', 'ot-h-time', clock(h.at))); + row.append(el('span', 'ot-h-kw', h.keyword)); + row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : '')); + row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕')); + list.append(row); + } + box.append(list); + } + } + + function applyStatus() { + if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash'); + const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting'; + moduleApi.setStatus(s, state.message || ''); + } + + function handleState(e) { + try { + state = JSON.parse(e.data); + } catch { + return; + } + streamDown = false; + applyStatus(); + render(); + } + + async function backfill() { + try { + const res = await moduleApi.fetch('/state'); + if (!res.ok) return; + const body = await res.json(); + if (!body || !body.state || !stream) return; + state = body.state; + streamDown = false; + applyStatus(); + render(); + } catch { + /* the stream will bring the next state */ + } + } + + async function refresh() { + if (!refreshBtn) return; + refreshBtn.classList.add('active'); + try { + const res = await moduleApi.fetch('/refresh', { method: 'POST' }); + if (!res.ok) { + let why = `Overflow refresh failed (${res.status})`; + try { why = (await res.json()).error || why; } catch { /* keep the status */ } + moduleApi.setStatus('error', why); + } + } catch (err) { + moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`); + } finally { + refreshBtn.classList.remove('active'); + } + } + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting'); + refreshBtn = moduleApi.header.addButton({ + icon: ICON_REFRESH, + title: 'Reload Overflow', + onClick: refresh, + }); + stream = moduleApi.sse('/stream', { + // A fresh connection replays the state; a tile joining a stream the + // page already carries gets only `open`, and the server sends state + // on change alone — so backfill, or a second tile waits for the next + // switch to draw anything. + open() { backfill(); }, + error() { + moduleApi.setStatus('error', 'Lost the connection to ProdDash'); + streamDown = true; + render(); + }, + events: { state: handleState }, + }); + render(); + }, + + stop() { + if (stream) stream.close(); + stream = null; + refreshBtn = null; + root.textContent = ''; + }, + + onConfigChange() { + render(); + }, + }; +} diff --git a/modules/overflow-tap/keywords.js b/modules/overflow-tap/keywords.js new file mode 100644 index 0000000..6727b9a --- /dev/null +++ b/modules/overflow-tap/keywords.js @@ -0,0 +1,101 @@ +'use strict'; + +/** + * Keyword mappings — reading the admin page's Keywords text and deciding + * which keyword a slide's notes carry. + * + * Pure functions, no I/O, so keywords.selftest.js can pin every rule down: + * + * parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }] + * keywordMatches(notes, keyword, mode) → boolean + * matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null + * cleanNotes(raw) → the notes with ProPresenter's invisible padding removed + * + * The Keywords text is one mapping per line: + * + * give = Give Online + * connect -> Connect Card @ Lobby, Kids Wing + * # a comment + * + * `=`, `->` and `→` all separate the keyword from the destination. A trailing + * ` @ ` names the Tap groups this line switches (by name or id); without it the + * admin page's chosen groups apply. The destination is the name Overflow shows + * for it, or its id — resolving that against the account is the server's job, + * so a parse never needs the network. + */ + +const ZERO_WIDTH = /[​-‍⁠]/g; + +function cleanNotes(raw) { + return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n'); +} + +function norm(s) { + return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function parseKeywordLines(text) { + const out = []; + const seen = new Set(); + cleanNotes(text).split('\n').forEach((raw, i) => { + const line = raw.trim(); + if (!line || line.startsWith('#')) return; + const m = /^(.*?)\s*(?:=|->|→)\s*(.*)$/.exec(line); + const entry = { line: i + 1, keyword: '', destination: '', groups: [], problem: '' }; + if (!m) { + entry.keyword = line; + entry.problem = 'No destination — write it as "keyword = Destination name"'; + out.push(entry); + return; + } + entry.keyword = m[1].trim(); + let rest = m[2].trim(); + const at = rest.indexOf('@'); + if (at >= 0) { + entry.groups = rest.slice(at + 1).split(',').map((s) => s.trim()).filter(Boolean); + rest = rest.slice(0, at).trim(); + } + entry.destination = rest; + if (!entry.keyword) entry.problem = 'No keyword before the ='; + else if (!entry.destination) entry.problem = 'No destination after the ='; + else if (seen.has(norm(entry.keyword))) entry.problem = 'Repeated keyword — the first line wins'; + if (entry.keyword) seen.add(norm(entry.keyword)); + out.push(entry); + }); + return out; +} + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Does this slide's notes carry the keyword? + * word — the keyword (a word or a phrase) bounded by the start/end of the + * notes or by something that is not a letter, digit or underscore. + * `give` fires on "give" and "give!" but not on "forgiveness". + * anywhere — plain substring, what Overflow's own helper does. + * line — a line of the notes is exactly the keyword. + * All three ignore case and collapse runs of whitespace inside a phrase. + */ +function keywordMatches(notes, keyword, mode = 'word') { + const n = cleanNotes(notes); + const k = norm(keyword); + if (!k || !n.trim()) return false; + if (mode === 'line') return n.split('\n').some((l) => norm(l) === k); + const flat = n.toLowerCase().replace(/\s+/g, ' '); + if (mode === 'anywhere') return flat.includes(k); + const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u'); + return re.test(flat); +} + +/** The first mapping (in admin order) whose keyword the notes carry, or null. */ +function matchKeyword(notes, mappings, mode = 'word') { + for (const m of mappings || []) { + if (!m || !m.keyword) continue; + if (keywordMatches(notes, m.keyword, mode)) return m; + } + return null; +} + +module.exports = { cleanNotes, norm, parseKeywordLines, keywordMatches, matchKeyword }; diff --git a/modules/overflow-tap/keywords.selftest.js b/modules/overflow-tap/keywords.selftest.js new file mode 100644 index 0000000..049dde8 --- /dev/null +++ b/modules/overflow-tap/keywords.selftest.js @@ -0,0 +1,115 @@ +'use strict'; + +/** + * Self-test for keywords.js — the parsing and matching rules, pinned. + * + * node modules/overflow-tap/keywords.selftest.js + */ + +const assert = require('node:assert/strict'); +const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords'); + +let n = 0; +function test(name, fn) { + n += 1; + try { + fn(); + console.log(`ok ${name}`); + } catch (err) { + console.log(`FAIL ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +test('parses keyword = destination, with -> and → too', () => { + const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request'); + assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [ + ['give', 'Give Online', ''], + ['connect', 'Connect Card', ''], + ['prayer', 'Prayer Request', ''], + ]); +}); + +test('a trailing @ names the groups for that line', () => { + const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing'); + assert.equal(m.destination, 'Connect Card'); + assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']); +}); + +test('blank lines and # comments are skipped, line numbers kept', () => { + const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n'); + assert.equal(m.length, 1); + assert.equal(m[0].line, 4); +}); + +test('a line without a separator is a problem, not a crash', () => { + const [m] = parseKeywordLines('give'); + assert.equal(m.keyword, 'give'); + assert.match(m.problem, /No destination/); +}); + +test('missing keyword or destination is reported', () => { + const [a, b] = parseKeywordLines('= Give Online\ngive ='); + assert.match(a.problem, /No keyword/); + assert.match(b.problem, /No destination/); +}); + +test('a repeated keyword is flagged and the first line wins', () => { + const m = parseKeywordLines('give = A\nGIVE = B'); + assert.equal(m[0].problem, ''); + assert.match(m[1].problem, /Repeated/); + assert.equal(matchKeyword('give', m).destination, 'A'); +}); + +test('keywords and destinations may be phrases', () => { + const [m] = parseKeywordLines('next steps = Next Steps Card'); + assert.equal(m.keyword, 'next steps'); + assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps', 'word'), true); +}); + +test('whole word: give does not fire on forgiveness, does on "give!"', () => { + assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'word'), false); + assert.equal(keywordMatches('give!', 'give', 'word'), true); + assert.equal(keywordMatches('Give', 'give', 'word'), true); + assert.equal(keywordMatches('#give now', 'give', 'word'), true); + assert.equal(keywordMatches('giveaway', 'give', 'word'), false); + assert.equal(keywordMatches('give_now', 'give', 'word'), false); +}); + +test('anywhere: plain substring, like the Overflow helper', () => { + assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'anywhere'), true); + assert.equal(keywordMatches('nothing here', 'give', 'anywhere'), false); +}); + +test('line: the keyword on its own line only', () => { + assert.equal(keywordMatches('speaker note\ngive\nmore', 'give', 'line'), true); + assert.equal(keywordMatches('give now', 'give', 'line'), false); + assert.equal(keywordMatches(' Give ', 'give', 'line'), true); +}); + +test('regex characters in a keyword are literal', () => { + assert.equal(keywordMatches('go to (give) now', '(give)', 'word'), true); + assert.equal(keywordMatches('give.now', 'give.now', 'anywhere'), true); + assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false); +}); + +test('empty notes never match; zero-width padding is ignored', () => { + assert.equal(keywordMatches('', 'give'), false); + assert.equal(keywordMatches('​', 'give', 'anywhere'), false); + assert.equal(cleanNotes('​give'), 'give'); + assert.equal(keywordMatches('​give', 'give', 'line'), true); +}); + +test('matchKeyword returns the first mapping in admin order', () => { + const m = parseKeywordLines('connect = Connect Card\ngive = Give Online'); + assert.equal(matchKeyword('give and connect', m).keyword, 'connect'); + assert.equal(matchKeyword('just worship', m), null); + assert.equal(matchKeyword('give', []), null); +}); + +test('a mapping with a parse problem can still be the match (the caller decides)', () => { + const m = parseKeywordLines('baptism'); + assert.equal(matchKeyword('baptism', m).problem.length > 0, true); +}); + +console.log(`${n} tests, exit ${process.exitCode || 0}`); diff --git a/modules/overflow-tap/module.json b/modules/overflow-tap/module.json new file mode 100644 index 0000000..4fec966 --- /dev/null +++ b/modules/overflow-tap/module.json @@ -0,0 +1,144 @@ +{ + "id": "overflow-tap", + "name": "Overflow Tap", + "version": "1.0.0", + "proddash": ">=1.11.0", + "description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes", + "permissions": [ + { + "kind": "local-network", + "reason": "Reaching ProPresenter on this network" + } + ], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 2, + "h": 2 + }, + "defaultSize": { + "w": 4, + "h": 3 + }, + "configGroups": { + "ProPresenter": { + "help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes are read — nothing on the stage screens changes." + }, + "Overflow": { + "collapsed": "whenSet", + "help": "API credentials from the Overflow Dashboard: Developers › API Keys (a READ_WRITE key). Tap groups and destinations are read from the account once these are saved; the groups list below fills in from there." + }, + "Keywords": { + "help": "One keyword per line, as `keyword = Destination name` — the destination exactly as it is named in Overflow's Tap module (or its id). Add ` @ Group A, Group B` to a line to switch only those groups instead of the ones chosen above. When a slide's notes carry one of these keywords, every chosen group is pointed at that destination; a slide without a keyword changes nothing. When several match, the first line wins." + }, + "Alerts": { + "toggle": "alertEnabled", + "collapsed": true, + "help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes." + } + }, + "configSchema": { + "propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "group": "ProPresenter", + "default": { + "host": "", + "port": 1025 + } + }, + "password": { + "type": "password", + "label": "API password", + "group": "ProPresenter", + "default": "", + "help": "Only if a password is set in ProPresenter's network preferences." + }, + "clientId": { + "type": "string", + "label": "Client ID", + "group": "Overflow", + "default": "" + }, + "apiKey": { + "type": "password", + "label": "API key", + "group": "Overflow", + "default": "" + }, + "groups": { + "type": "multiselect", + "label": "Tap groups", + "group": "Overflow", + "default": [], + "optionsRoute": "/groups", + "help": "The groups a keyword switches. None chosen means every group on the account." + }, + "keywords": { + "type": "text", + "label": "Keywords", + "group": "Keywords", + "default": "", + "help": "One per line — give = Give Online" + }, + "matching": { + "type": "select", + "label": "Match", + "group": "Keywords", + "default": "word", + "options": [ + { + "value": "word", + "label": "Whole word in the notes" + }, + { + "value": "anywhere", + "label": "Anywhere in the notes" + }, + { + "value": "line", + "label": "On its own line" + } + ], + "help": "Whole word keeps `give` from firing on `forgiveness`; anywhere is what Overflow's own helper does." + }, + "alertEnabled": { + "type": "switch", + "label": "Alert when a switch fails", + "group": "Alerts", + "default": false + }, + "alertChannel": { + "type": "number", + "label": "Channel", + "group": "Alerts", + "default": 1, + "min": 1, + "max": 6, + "help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu." + } + }, + "instanceSchema": { + "showDestination": { + "type": "switch", + "label": "Destination", + "default": true + }, + "showGroups": { + "type": "switch", + "label": "Groups", + "default": true + }, + "showSlide": { + "type": "switch", + "label": "Slide match", + "default": true + }, + "showHistory": { + "type": "switch", + "label": "History", + "default": true + } + } +} diff --git a/modules/overflow-tap/overflow.js b/modules/overflow-tap/overflow.js new file mode 100644 index 0000000..346df7c --- /dev/null +++ b/modules/overflow-tap/overflow.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * Overflow API client — the Tap part of Overflow's public REST API. + * + * https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json) + * + * Authentication is two headers, `x-client-id` and `x-api-key`, from the + * Overflow Dashboard's Developers › API Keys. 120 requests per minute per + * client; a 429 is answered when the window is spent. + * + * Reads used here are documented: GET /api/v3/tap/destinations, + * GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the + * `destinationId` it currently points at). + * + * THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only + * for Tap; Overflow's own ProPresenter helper changes a group's destination + * through the Dashboard's private API after signing in with a Dashboard + * login. `setGroupDestination` below sends the natural REST shape for this + * API — PATCH the group with the new `destinationId`, as PATCH /donors/{id} + * updates a donor — and the server confirms every switch by reading the group + * back, so an answer that changes nothing is reported, not believed. If + * Overflow publishes (or confirms) a different route, this one function is + * the only place to change. tools/overflow-tap-mock.js serves the same shape. + * + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock. + */ + +const DEFAULT_API_BASE = 'https://server.overflow.co'; +const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); +const TIMEOUT_MS = 10000; +const PAGE_SIZE = 100; +const MAX_PAGES = 20; +const ID_RE = /^[0-9a-f]{24}$/i; + +class OverflowError extends Error { + constructor(message, { status = 0, retryable = false } = {}) { + super(message); + this.name = 'OverflowError'; + this.status = status; + this.retryable = retryable; + } +} + +function looksLikeId(s) { + return ID_RE.test(String(s || '').trim()); +} + +function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) { + const id = String(clientId || '').trim(); + const key = String(apiKey || '').trim(); + + async function request(method, path, { query, body } = {}) { + if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 }); + const url = new URL(API_BASE + path); + for (const [k, v] of Object.entries(query || {})) { + if (v === undefined || v === null || v === '') continue; + url.searchParams.set(k, String(v)); + } + const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); + let res; + try { + res = await fetchImpl(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: ctl.signal, + }); + } catch (err) { + const aborted = err && err.name === 'AbortError'; + const cause = err && err.cause && (err.cause.code || err.cause.message); + throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true }); + } finally { + clearTimeout(timer); + } + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = null; } + } + if (!res.ok) { + const detail = data && (data.message || data.error || data.detail); + const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText); + let message; + if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`; + else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`; + else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)'; + else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`; + throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 }); + } + return data; + } + + /** Every page of a collection, newest-first as the API sorts it. */ + async function listAll(path, query = {}) { + const all = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } }); + const data = Array.isArray(res && res.data) ? res.data : []; + all.push(...data); + const total = Number(res && res.totalCount); + if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break; + } + return all; + } + + return { + base: API_BASE, + configured: Boolean(id && key), + listDestinations: () => listAll('/api/v3/tap/destinations'), + listGroups: () => listAll('/api/v3/tap/groups'), + async getGroup(groupId) { + const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`); + return res && res.data ? res.data : res; + }, + /** See the note at the top of this file: the one unverified call. */ + setGroupDestination(groupId, destinationId) { + return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } }); + }, + }; +} + +module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE }; diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js new file mode 100644 index 0000000..149f3ff --- /dev/null +++ b/modules/overflow-tap/server.js @@ -0,0 +1,552 @@ +'use strict'; + +/** + * Overflow Tap — server entry. + * + * Does what Overflow's "ProPresenter Integration" helper app does, inside + * ProdDash: watches the live slide in ProPresenter, and when its notes carry + * one of the admin page's keywords, points the chosen Overflow Tap groups at + * that keyword's destination — so what people reach when they tap a disc + * follows what is on screen. A slide with no keyword changes nothing; the + * last destination stays until the next keyworded slide. + * + * GET /state → the view-model every tile draws + * GET /stream → SSE, `state` events on every change (heartbeated) + * POST /refresh → re-read Overflow's groups and destinations now + * GET /groups → optionsRoute for the admin page's Tap groups picker + * GET /destinations → the destinations, for a quick look while setting up + * + * ProPresenter is polled (GET /v1/status/slide, twice a second — one small + * request, nothing heavier) because its chunked update stream is the thing + * that goes quiet without saying so. Overflow is read on start, every few + * minutes after that, and on /refresh; every switch is confirmed by reading + * the group back. Which Overflow call does the switching, and why that call + * is the one unverified piece, is explained at the top of overflow.js. + * + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at + * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602. + */ + +const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow'); +const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords'); + +const POLL_MS = 500; // ProPresenter, while it answers +const RETRY_MS = 3000; // ProPresenter, while it does not +const PP_TIMEOUT_MS = 4000; +const HEARTBEAT_MS = 15000; +const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000; +const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed +const OVERFLOW_RETRY_MS = 30 * 1000; +const SWITCH_RETRY_MS = 10 * 1000; +const SWITCH_RETRIES = 3; +const HISTORY_MAX = 8; // the tile shows four; a few more for the hover +const ALERT_SECONDS = 30; +const MATCH_MODES = new Set(['word', 'anywhere', 'line']); + +/** Set by init(), read by routes() — both are rebuilt together on a config save. */ +let current = null; + +function json(res, code, body) { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); +} + +/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */ +function describe(err) { + if (err instanceof OverflowError) return err.message; + if (err && err.name === 'AbortError') return 'timed out'; + const msg = err && err.message ? err.message : String(err); + const cause = err && err.cause && (err.cause.code || err.cause.message); + return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg; +} + +module.exports = { + init({ config, log, shell }) { + /* ── config ────────────────────────────────────────────────────── */ + const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; + const host = String(ep.host || '').trim(); + const port = Number(ep.port) || 1025; + const password = String(config.password || ''); + const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean); + const mappings = parseKeywordLines(config.keywords); + const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word'; + const alertEnabled = Boolean(config.alertEnabled); + const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1))); + + const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey }); + + /* ── state ─────────────────────────────────────────────────────── */ + const streams = new Set(); + const timers = new Set(); + let stopped = false; + let lastFrame = ''; + + let destinations = []; // Overflow's, as listed + let groups = []; // Overflow's, as listed (each with its current destinationId) + let resolved = []; // the mappings, resolved against those two lists + let lastSlideKey = null; // change detection on the live slide + let wanted = null; // the resolved keyword Tap should be on + let inFlight = false; + let retriesLeft = 0; + let retryTimer = null; + let alertRaised = false; + + const state = { + status: 'connecting', + message: '', + propresenter: { configured: Boolean(host), reachable: false, error: '', host, port }, + overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 }, + keywords: [], + slide: { live: false, keyword: '', unmapped: '', hasNotes: false }, + current: null, + history: [], + updatedAt: 0, + }; + + /* ── timers that die with the handle ───────────────────────────── */ + + function later(fn, ms) { + const t = setTimeout(() => { + timers.delete(t); + if (!stopped) fn(); + }, ms); + t.unref?.(); + timers.add(t); + return t; + } + + /* ── health, shared by the admin line and the tiles ────────────── */ + + function assess() { + const pp = state.propresenter; + const of = state.overflow; + if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin']; + if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin']; + if (!mappings.length) return ['error', 'No keywords configured — add some in /admin']; + if (!of.reachable) return ['error', of.error || 'Reaching Overflow…']; + if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; + const bad = state.keywords.filter((k) => k.problem); + if (bad.length && bad.length === state.keywords.length) { + return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`]; + } + if (state.current && !state.current.pending && !state.current.ok) { + return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; + } + if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; + const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : ''; + const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : ''; + return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`]; + } + + function publish() { + if (stopped) return; + [state.status, state.message] = assess(); + const { updatedAt, ...rest } = state; + let frame; + try { + frame = JSON.stringify(rest); + } catch { + return; + } + if (frame === lastFrame) return; + lastFrame = frame; + state.updatedAt = Date.now(); + const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + } + + /* ── Overflow: destinations, groups, and the keywords resolved ─── */ + + function resolveKeywords() { + const dById = new Map(destinations.map((d) => [String(d.id), d])); + const dByName = new Map(destinations.map((d) => [norm(d.name), d])); + const gById = new Map(groups.map((g) => [String(g.id), g])); + const gByName = new Map(groups.map((g) => [norm(g.name), g])); + const defaults = chosenGroups.length + ? chosenGroups.map((id) => gById.get(id)).filter(Boolean) + : groups.slice(); + resolved = mappings.map((m) => { + const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem }; + if (out.problem) return out; + const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination)); + if (!d) { + out.problem = destinations.length + ? `No Overflow destination named "${m.destination}"` + : 'Overflow destinations not read yet'; + return out; + } + out.destination = String(d.name || m.destination); + out.destinationId = String(d.id); + let gs; + if (m.groups.length) { + gs = []; + const missing = []; + for (const name of m.groups) { + const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name)); + if (g) gs.push(g); + else missing.push(name); + } + if (missing.length) { + out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`; + return out; + } + } else { + gs = defaults; + } + if (!gs.length) { + out.problem = groups.length + ? 'None of the chosen Tap groups exist any more' + : 'No Tap groups on this Overflow account'; + return out; + } + out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) })); + return out; + }); + state.keywords = resolved.map((r) => ({ + keyword: r.keyword, + destination: r.destination, + groups: r.groups.map((g) => g.name), + problem: r.problem, + })); + } + + /** Has anyone moved a group we switched? Noted on the live entry, never fought. */ + function noteDrift() { + const cur = state.current; + if (!cur || !cur.ok || cur.pending) return; + const byId = new Map(groups.map((g) => [String(g.id), g])); + const moved = []; + for (const g of cur.groupIds || []) { + const now = byId.get(g); + if (!now) continue; + if (String(now.destinationId || '') !== cur.destination.id) { + moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`); + } + } + cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : ''; + } + + let destinationsTimer = null; + let groupsTimer = null; + let loading = null; + + async function loadOverflow(what = 'all') { + if (stopped || !overflow.configured) return; + if (loading) return loading; + loading = (async () => { + try { + if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations(); + groups = await overflow.listGroups(); + state.overflow.reachable = true; + state.overflow.error = ''; + state.overflow.groups = groups.length; + state.overflow.destinations = destinations.length; + state.overflow.loadedAt = Date.now(); + resolveKeywords(); + noteDrift(); + // A slide that was already up when the lists arrived (or when a + // destination got its name fixed in Overflow) should take effect now. + if (lastSlideKey !== null) reconsiderSlide(); + } catch (err) { + state.overflow.reachable = false; + state.overflow.error = describe(err); + log(`Overflow: ${state.overflow.error}`); + if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines + } + publish(); + })(); + try { + await loading; + } finally { + loading = null; + } + scheduleOverflow(); + } + + function scheduleOverflow() { + if (stopped) return; + if (destinationsTimer) clearTimeout(destinationsTimer); + if (groupsTimer) clearTimeout(groupsTimer); + timers.delete(destinationsTimer); + timers.delete(groupsTimer); + const ok = state.overflow.reachable; + destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS); + groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null; + } + + /* ── the switch ────────────────────────────────────────────────── */ + + function pushHistory(entry) { + const { pending, drift, ...rest } = entry; + state.history.unshift(rest); + if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; + } + + function raiseAlert(keyword) { + if (!alertEnabled || !shell || typeof shell.alert !== 'function') return; + alertRaised = true; + Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS })) + .catch((err) => log(`alert refused: ${describe(err)}`)); + } + + function clearAlert() { + if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return; + alertRaised = false; + Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {}); + } + + function requestSwitch(target) { + wanted = target; + retriesLeft = SWITCH_RETRIES; + if (retryTimer) { + clearTimeout(retryTimer); + timers.delete(retryTimer); + retryTimer = null; + } + runSwitch(); + } + + async function runSwitch() { + if (stopped || inFlight || !wanted) return; + const target = wanted; + const cur = state.current; + const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId; + if (sameTarget && cur.ok) return; // already there + inFlight = true; + let entry; + if (sameTarget) { + entry = cur; // another go at the same switch + } else { + if (cur) pushHistory(cur); + entry = { + keyword: target.keyword, + destination: { id: target.destinationId, name: target.destination }, + groups: target.groups.map((g) => g.name), + groupIds: target.groups.map((g) => g.id), + at: Date.now(), + ok: false, + pending: true, + error: '', + attempts: 0, + drift: '', + }; + state.current = entry; + } + entry.pending = true; + entry.attempts += 1; + publish(); + try { + for (const g of target.groups) { + if (stopped) return; + await overflow.setGroupDestination(g.id, target.destinationId); + const check = await overflow.getGroup(g.id); + const nowAt = check ? String(check.destinationId || '') : ''; + if (check && nowAt !== target.destinationId) { + const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination'; + throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 }); + } + const cached = groups.find((x) => String(x.id) === g.id); + if (cached) { + cached.destinationId = target.destinationId; + cached.destination = { id: target.destinationId, name: target.destination }; + } + } + entry.ok = true; + entry.error = ''; + entry.at = Date.now(); + entry.drift = ''; + clearAlert(); + log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`); + } catch (err) { + if (stopped) return; + entry.ok = false; + entry.error = describe(err); + log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`); + if (retriesLeft > 0) { + retriesLeft -= 1; + retryTimer = later(() => { + retryTimer = null; + runSwitch(); + }, SWITCH_RETRY_MS); + } + raiseAlert(target.keyword); + } finally { + entry.pending = false; + inFlight = false; + publish(); + if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile + } + } + + /* ── ProPresenter: the live slide ──────────────────────────────── */ + + function ppUrl(target) { + const u = new URL(`http://${host}:${port}${target}`); + if (password) u.searchParams.set('password', password); + return u; + } + + async function ppJson(target) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS); + try { + const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal }); + if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`); + const text = await res.text(); + return text ? JSON.parse(text) : null; + } finally { + clearTimeout(timer); + } + } + + let lastSlide = null; + + function reconsiderSlide() { + const cur = lastSlide; + if (!cur) { + state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false }; + return; + } + const notes = cleanNotes(cur.notes); + const hit = matchKeyword(notes, resolved, mode); + state.slide = { + live: true, + keyword: hit ? hit.keyword : '', + unmapped: hit && hit.problem ? hit.problem : '', + hasNotes: Boolean(notes.trim()), + }; + if (hit && !hit.problem) requestSwitch(hit); + } + + async function pollSlide() { + if (stopped) return; + let ok = false; + try { + const data = await ppJson('/v1/status/slide'); + const cur = data && data.current && typeof data.current === 'object' ? data.current : null; + state.propresenter.reachable = true; + state.propresenter.error = ''; + ok = true; + const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; + if (key !== lastSlideKey) { + lastSlideKey = key; + lastSlide = cur; + reconsiderSlide(); + } + } catch (err) { + state.propresenter.reachable = false; + state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err); + } + publish(); + later(pollSlide, ok ? POLL_MS : RETRY_MS); + } + + /* ── go ────────────────────────────────────────────────────────── */ + + resolveKeywords(); + if (host) { + pollSlide(); + log(`following ProPresenter at ${host}:${port}`); + } else { + log('no ProPresenter host configured — set one in /admin'); + } + if (overflow.configured) { + loadOverflow('all'); + log(`Overflow at ${overflow.base}`); + } else { + log('Overflow client ID / API key not set — add them in /admin'); + } + publish(); + + const heartbeat = setInterval(() => { + for (const res of streams) { + try { + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + const handle = { + streams, + getState: () => state, + refresh: () => loadOverflow('all'), + groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })), + destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })), + configured: overflow.configured, + }; + current = handle; + + return { + stop() { + stopped = true; + clearInterval(heartbeat); + for (const t of timers) clearTimeout(t); + timers.clear(); + clearAlert(); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current === handle) current = null; + }, + health() { + const [status, message] = assess(); + return { status, message }; + }, + }; + }, + + routes() { + return { + 'GET /state': (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + json(res, 200, { state: current.getState() }); + }, + + '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'); + if (!current) return void res.end(); + res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`); + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + current.streams.add(res); + req.on('close', () => current && current.streams.delete(res)); + }, + + 'POST /refresh': async (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' }); + await current.refresh(); + const st = current.getState(); + if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' }); + json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations }); + }, + + 'GET /groups': async (req, res) => { + if (!current) return json(res, 200, { options: [] }); + if (current.configured && !current.groupOptions().length) await current.refresh(); + json(res, 200, { options: current.groupOptions() }); + }, + + 'GET /destinations': async (req, res) => { + if (!current) return json(res, 200, { options: [] }); + if (current.configured && !current.destinationOptions().length) await current.refresh(); + json(res, 200, { options: current.destinationOptions() }); + }, + }; + }, +}; diff --git a/modules/overflow-tap/style.css b/modules/overflow-tap/style.css new file mode 100644 index 0000000..9a43d86 --- /dev/null +++ b/modules/overflow-tap/style.css @@ -0,0 +1,213 @@ +/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and + styled with the shell's variables only. Sizes hang off the tile (container + units) so the keyword reads from across a booth on a big tile and still + fits a 2×2. */ + +[data-module="overflow-tap"] { + container-type: size; +} + +[data-module="overflow-tap"] .ot-root { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: stretch; + padding: clamp(4px, 4cqmin, 18px); + gap: clamp(2px, 1.8cqmin, 10px); + overflow: hidden; + color: var(--text); + user-select: none; + -webkit-user-select: none; +} + +[data-module="overflow-tap"] .ot-offline { + flex-shrink: 0; + font-size: clamp(0.6rem, 4.6cqmin, 0.9rem); + font-weight: 700; + color: var(--danger); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="overflow-tap"] .ot-root.is-offline .ot-main, +[data-module="overflow-tap"] .ot-root.is-offline .ot-slide, +[data-module="overflow-tap"] .ot-root.is-offline .ot-history { + opacity: 0.45; + filter: grayscale(0.6); +} + +/* ── the live keyword ───────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-main { + --tone: var(--accent); + flex: 1 1 auto; + min-height: 0; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + text-align: center; + overflow: hidden; +} +[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); } +[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); } +[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); } +[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); } + +[data-module="overflow-tap"] .ot-label { + color: var(--tone); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + font-weight: 700; + letter-spacing: 0.14em; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +[data-module="overflow-tap"] .ot-keyword { + color: var(--tone); + font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw); + font-weight: 750; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); } +[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword { + animation: ot-pulse 1.2s ease-in-out infinite; +} +@keyframes ot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +[data-module="overflow-tap"] .ot-dest { + color: var(--text); + font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw); + font-weight: 600; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; } + +[data-module="overflow-tap"] .ot-groups { + color: var(--muted); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + line-height: 1.3; + flex-shrink: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-module="overflow-tap"] .ot-error { + color: var(--danger); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + line-height: 1.3; + max-width: 100%; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); } + +/* ── this slide ─────────────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-slide { + flex-shrink: 0; + text-align: center; + font-size: clamp(0.55rem, 4.8cqmin, 0.95rem); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0.25em 0.6em; + border-radius: 999px; + background: color-mix(in srgb, var(--text) 7%, transparent); + align-self: center; + max-width: 100%; +} +[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); } +[data-module="overflow-tap"] .ot-slide.is-hit { + color: var(--accent-text); + background: color-mix(in srgb, var(--accent) 80%, transparent); + font-weight: 650; +} +[data-module="overflow-tap"] .ot-slide.is-warn { + color: var(--warn); + background: color-mix(in srgb, var(--warn) 16%, transparent); +} + +/* ── the trail ──────────────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-history { + flex-shrink: 0; + list-style: none; + margin: 0; + padding: 0.3em 0 0; + border-top: 1px solid var(--border); + font-size: clamp(0.55rem, 4.6cqmin, 0.95rem); + display: flex; + flex-direction: column; + gap: 0.1em; +} + +[data-module="overflow-tap"] .ot-h-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto; + align-items: baseline; + gap: 0 0.7em; + color: var(--text); +} +[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw, +[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); } +[data-module="overflow-tap"] .ot-h-time { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; } +[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; } + +/* On a short tile the trail would crowd the keyword out: keep the + keyword, drop the trail. Inside the keyword block the same rule: when + it is short, the small lines go before the keyword does. */ +@container (max-height: 120px) { + [data-module="overflow-tap"] .ot-history { display: none; } +} +@container (max-height: 70px) { + [data-module="overflow-tap"] .ot-groups, + [data-module="overflow-tap"] .ot-error { display: none; } +} +@container (max-height: 46px) { + [data-module="overflow-tap"] .ot-dest, + [data-module="overflow-tap"] .ot-label { display: none; } +} + +[data-module="overflow-tap"] .ot-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 6%; + color: var(--muted); + text-align: center; + font-size: clamp(0.65rem, 5.5cqmin, 1rem); +} diff --git a/tools/overflow-tap-mock.js b/tools/overflow-tap-mock.js new file mode 100644 index 0000000..024c4f4 --- /dev/null +++ b/tools/overflow-tap-mock.js @@ -0,0 +1,183 @@ +/** + * Mock ProPresenter slide status + mock Overflow Tap API, for developing the + * overflow-tap module without a live ProPresenter or an Overflow account. + * + * node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000] + * [--key=] [--read-only] [--slow] + * + * ProPresenter side (127.0.0.1:1602 by default) — the one route the module + * uses, with a service that advances every --step ms: + * GET /version reachability probe + * GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling + * through slides whose notes carry keywords + * ("give", "Next steps: connect", "prayer"), slides + * with other notes ("forgiveness" — must NOT fire + * "give" in whole-word mode), a slide whose keyword + * maps to nothing on the account, and a stretch with + * nothing live. + * + * Overflow side (127.0.0.1:24720 by default; run ProdDash with + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with + * paging, and the switch the module sends: + * GET /api/v3/tap/destinations four destinations + * GET /api/v3/tap/groups three groups, each pointing somewhere + * GET /api/v3/tap/groups/{id} + * PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group. + * --read-only answers 404 instead, which + * is what Overflow's public API does + * today, to exercise the failure path. + * Every request must carry x-client-id and x-api-key (any non-empty values; + * --key= demands that exact key, answering 401 otherwise). --slow adds + * a two-second delay to every Overflow answer. + * + * Suggested admin Keywords for this mock: + * give = Give Online + * connect = Connect Card @ Lobby + * prayer = Prayer Request + * baptism = Baptism Signup + * + * Every incoming request is logged so we can confirm what the module does. + */ +'use strict'; + +const http = require('http'); + +const args = process.argv.slice(2); +const opt = (name, dflt) => { + const a = args.find((x) => x.startsWith(`--${name}=`)); + return a ? a.slice(name.length + 3) : dflt; +}; +const PP_PORT = Number.parseInt(opt('pp', '1602'), 10); +const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10); +const STEP_MS = Number.parseInt(opt('step', '4000'), 10); +const KEY = opt('key', ''); +const READ_ONLY = args.includes('--read-only'); +const SLOW = args.includes('--slow'); + +/* ── ProPresenter ────────────────────────────────────────────────── */ + +const SLIDES = [ + { uuid: 'S1', text: 'Welcome to Waters Church', notes: '' }, + { uuid: 'S2', text: 'Give online', notes: 'give' }, + { uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' }, + { uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' }, + { uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' }, + { uuid: 'S6', text: 'Pray with us', notes: 'prayer' }, + { uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' }, + null, // nothing live + { uuid: 'S8', text: 'Give online (again)', notes: 'give' }, + { uuid: 'S9', text: 'See you next week', notes: '' }, +]; +let slideIdx = 0; +setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref(); + +function slidePayload() { + const cur = SLIDES[slideIdx]; + const nxt = SLIDES[(slideIdx + 1) % SLIDES.length]; + return { + current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null, + next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null, + }; +} + +function sendJson(res, code, body, extra = {}) { + res.writeHead(code, { 'Content-Type': 'application/json', ...extra }); + res.end(JSON.stringify(body)); +} + +http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const p = url.pathname; + console.log(`[pp] ${req.method} ${p}`); + if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' }); + if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload()); + if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx]), media: false, announcements: false }); + sendJson(res, 404, { error: 'not found' }); +}).listen(PP_PORT, '127.0.0.1', () => { + console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms)`); +}); + +/* ── Overflow ────────────────────────────────────────────────────── */ + +const stamp = (d) => new Date(d).toISOString(); +const DESTINATIONS = [ + { id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null }, + { id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null }, + { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null }, + { id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' }, +].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); + +const GROUPS = [ + { id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] }, + { id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] }, + { id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] }, +].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); + +function destView(id) { + const d = DESTINATIONS.find((x) => x.id === id); + return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null; +} +function groupView(g) { + return { ...g, destination: destView(g.destinationId) }; +} +function page(list, url) { + const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25)); + const pg = Math.max(1, Number(url.searchParams.get('page')) || 1); + const start = (pg - 1) * limit; + return { data: list.slice(start, start + limit), totalCount: list.length }; +} + +let served = 0; +http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const p = url.pathname; + served += 1; + const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' }; + const answer = (code, body) => { + const go = () => sendJson(res, code, body, rl); + if (SLOW) setTimeout(go, 2000); + else go(); + }; + const cid = String(req.headers['x-client-id'] || ''); + const key = String(req.headers['x-api-key'] || ''); + console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`); + if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' }); + + if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url)); + const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p); + if (req.method === 'GET' && dOne) { + const d = DESTINATIONS.find((x) => x.id === dOne[1]); + return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' }); + } + if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url)); + const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p); + if (gOne) { + const g = GROUPS.find((x) => x.id === gOne[1]); + if (!g) return answer(404, { statusCode: 404, message: 'Group not found' }); + if (req.method === 'GET') return answer(200, { data: groupView(g) }); + if (req.method === 'PATCH' || req.method === 'PUT') { + if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` }); + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + let parsed = {}; + try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); } + const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId); + if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' }); + g.destinationId = dest.id; + g.updatedAt = stamp(Date.now()); + console.log(`[overflow] ${g.name} → ${dest.name}`); + answer(200, { data: groupView(g) }); + }); + return; + } + } + if (req.method === 'GET' && p === '/api/v3/tap/devices') { + const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id }))); + return answer(200, page(devices, url)); + } + if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 }); + answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` }); +}).listen(OF_PORT, '127.0.0.1', () => { + console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`); +}); From 459d8b9c38b3b683b172379fd54f796562dcd14d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 11 Oct 2026 10:25:02 -0400 Subject: [PATCH 06/23] Overflow Tap: speak Overflow's Dashboard API, as its own helper app does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut guessed a write route on Overflow's public API, which documents only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the helper makes, against the Dashboard's own backend: POST /nonprofit-dashboard/authentication/login/jwt a Dashboard sign-in → JWT GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords the Keyword Mappings POST …/nonprofits/{id}/keywords?syncNonprofit=true { keywords } = the switch So the keyword → destination → Tap groups mapping stays where Overflow keeps it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read from there: on start, every five minutes, on the tile's refresh button, and from every trigger's answer. Admin config shrinks to the ProPresenter endpoint, the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co staff login) and the alert switch; the API-key, Tap-groups, keyword-text and match-mode fields are gone. Behaviour follows the helper: both the presentation layer (/v1/status/slide) and the announcement layer (/v1/announcement/slide_index + /active) are watched; when a layer's set of matched keywords changes to a non-empty set it is sent; matching is per word, case-insensitive, punctuation around the word ignored. The session token is cached across re-inits for the same login so an admin saving config repeatedly cannot trip Overflow's "too many attempts"; a 401 signs in again once; a refused sign-in is not retried within 20 s. tools/overflow-tap-mock.js now plays the Dashboard API (any email/password signs in; --fail-switch, --expire=, --login=:) and gives the mock ProPresenter an announcement layer (--no-announcements for an older one). Verified against it: sign-in, keyword load, triggers on both layers, token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login → the reason on the tile and in the health line, and recovery. keywords.selftest.js pins the matching rule (8 cases). Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- README.md | 52 ++- modules/overflow-tap/client.js | 44 +- modules/overflow-tap/keywords.js | 99 ++-- modules/overflow-tap/keywords.selftest.js | 107 ++--- modules/overflow-tap/module.json | 52 +-- modules/overflow-tap/overflow.js | 188 +++++--- modules/overflow-tap/server.js | 524 +++++++++++++--------- tools/overflow-tap-mock.js | 292 +++++++----- 9 files changed, 738 insertions(+), 622 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a21ee7f..e18e45f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ title-bar controls). The rules that matter most: `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). `node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`). - `node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Tap API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; `--read-only` fails every switch). + `node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=:`). ## Reference code (read-only forks — don't develop these here) diff --git a/README.md b/README.md index 47e18b1..b93d5c9 100644 --- a/README.md +++ b/README.md @@ -510,26 +510,27 @@ below. Nothing is installed without someone clicking. read from the account), and a folded **Timing** group for the poll intervals and a position trim. Tile settings: cue name, previous cue, playhead, cue list. -- **Overflow Tap** — what Overflow's own ProPresenter helper app does, - inside ProdDash: the live slide's notes are read from ProPresenter, and - when they carry one of the admin page's keywords, the chosen Overflow Tap - groups are pointed at that keyword's destination — so a tap on a disc - follows what is on screen. A slide with no keyword changes nothing. The - tile is one keyword, big: what Tap is on (and since when), its destination - and the groups moved, a chip saying whether the slide on screen carries a - keyword, and the last four switches with their times; a failed switch - turns the tile red with the reason and, if switched on in admin, flashes - an alert channel. Admin config: the ProPresenter endpoint, the Overflow - API credentials (Developers › API Keys in the Overflow Dashboard), the Tap - groups to switch (read from the account), the keywords — one per line as - `give = Give Online`, optionally `@ Lobby, Kids Wing` to switch only - those groups — and how a keyword matches (whole word, anywhere, or on its - own line). Tile settings are four switches: destination, groups, slide - match, history. **One thing is still unverified:** Overflow's public API - documents only *reads* for Tap, so the call that moves a group (`PATCH - /api/v3/tap/groups/{id}` with the new `destinationId`) is the natural - shape, not a documented one; every switch is confirmed by reading the - group back, so a refusal shows on the tile rather than passing silently. +- **Overflow Tap** — what Overflow's own "ProPresenter Integration" helper + app does, inside ProdDash, speaking the same Dashboard API it does: the + live slide's notes are read from ProPresenter (the presentation layer and + the announcement layer), and when they carry one of the keywords mapped in + the Overflow Dashboard, Overflow is told — and points that keyword's Tap + groups at its destination, so a tap on a disc follows what is on screen. + A slide with no keyword changes nothing. Keywords match as the helper + matches them: as a whole word of the notes, any case, punctuation around + it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is + one keyword, big: what Tap is on (and since when), its destination and the + groups moved, a chip saying whether the slide on screen carries a keyword, + and the last four switches with their times; a failed switch turns the + tile red with the reason, is retried, and — if switched on in admin — + flashes an alert channel. Admin config: the ProPresenter endpoint and an + **Overflow Dashboard login** (email and password; Google sign-in has no + password, so set one with "Forgot Your Password?"). The keywords, their + destinations and their Tap groups stay where Overflow keeps them — + Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are + read from there on start, every few minutes, and on the tile's refresh + button. Tile settings are four switches: destination, groups, slide + match, history. - **Band Lineup** — the backstage stage board: everyone on stage in their mic slot with photo, name and mic/pack label, on one shared board that every screen sees the same way — whoever edits it changes it everywhere, over SSE. @@ -582,10 +583,13 @@ run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and board — a plan, its team members, birthdays and photos — run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and `node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry -keywords on `127.0.0.1:1602` plus the Overflow Tap API on `127.0.0.1:24720` -— run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; -`--read-only` makes the switch fail the way the public API does today). See -the testing section of the [module guide](docs/MODULE-GUIDE.md). +keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow +Dashboard API on `127.0.0.1:24720` — run ProdDash with +`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with +any email and password; `--fail-switch` refuses every switch, +`--expire=20` makes sessions expire, `--login=:` accepts +one login only). See the testing section of the +[module guide](docs/MODULE-GUIDE.md). ## Install as a desktop web app diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js index 29f3a46..dee1221 100644 --- a/modules/overflow-tap/client.js +++ b/modules/overflow-tap/client.js @@ -45,8 +45,8 @@ export default function create({ root, moduleApi }) { const pp = state.propresenter || {}; const of = state.overflow || {}; if (!pp.configured) return 'No ProPresenter host — set one in /admin'; - if (!of.configured) return 'No Overflow API key — add it in /admin'; - if (!(state.keywords || []).length) return 'No keywords — add them in /admin'; + if (!of.configured) return 'No Overflow login — add it in /admin'; + if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet'; return ''; } @@ -78,12 +78,11 @@ export default function create({ root, moduleApi }) { if (cur) { // The label carries the state and, when there is one, the moment it // became true; the small lines under the destination are the groups - // moved — or, when the switch failed or was undone in Overflow, what - // went wrong, in their place (a tile has room for one or the other). + // moved — or, when the switch failed, what went wrong, in their place + // (a tile has room for one or the other). let label = `TAP IS ON · since ${clock(cur.at)}`; let tone = 'ok'; let note = ''; - let noteClass = 'ot-error'; if (cur.pending) { label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; tone = 'muted'; @@ -91,17 +90,12 @@ export default function create({ root, moduleApi }) { label = `NOT SWITCHED · ${clock(cur.at)}`; tone = 'alert'; note = cur.error || ''; - } else if (cur.drift) { - label = `TAP WAS ON · since ${clock(cur.at)}`; - tone = 'warn'; - note = cur.drift; - noteClass = 'ot-error is-warn'; } main.dataset.tone = tone; main.append(el('div', 'ot-label', label)); main.append(el('div', 'ot-keyword', cur.keyword)); - if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : '')); - if (note) main.append(el('div', noteClass, note)); + if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || '')); + if (note) main.append(el('div', 'ot-error', note)); else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); } else { main.dataset.tone = 'muted'; @@ -113,13 +107,21 @@ export default function create({ root, moduleApi }) { if (on('showSlide')) { const slide = state.slide || {}; + const ann = state.announcement || {}; + const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : []; + const annKeys = Array.isArray(ann.keywords) ? ann.keywords : []; let text; let cls = 'ot-slide'; if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; } - else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; } - else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; } - else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; } - else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; } + else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; } + else if (slideKeys.length || annKeys.length) { + const parts = []; + if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`); + if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`); + text = parts.join(' · '); + cls += ' is-hit'; + } else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; } + else { text = 'Announcement: no keyword'; cls += ' is-muted'; } box.append(el('div', cls, text)); } @@ -132,11 +134,11 @@ export default function create({ root, moduleApi }) { for (const h of rows) { const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`); row.title = h.ok - ? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}` + ? `${h.keyword} → ${h.destination || ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}` : `${h.keyword}: ${h.error || 'failed'}`; row.append(el('span', 'ot-h-time', clock(h.at))); row.append(el('span', 'ot-h-kw', h.keyword)); - row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : '')); + row.append(el('span', 'ot-h-dest', h.destination || '')); row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕')); list.append(row); } @@ -182,12 +184,12 @@ export default function create({ root, moduleApi }) { try { const res = await moduleApi.fetch('/refresh', { method: 'POST' }); if (!res.ok) { - let why = `Overflow refresh failed (${res.status})`; + let why = `Keyword refresh failed (${res.status})`; try { why = (await res.json()).error || why; } catch { /* keep the status */ } moduleApi.setStatus('error', why); } } catch (err) { - moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`); + moduleApi.setStatus('error', `Keyword refresh failed: ${err && err.message ? err.message : err}`); } finally { refreshBtn.classList.remove('active'); } @@ -198,7 +200,7 @@ export default function create({ root, moduleApi }) { moduleApi.setStatus('connecting', 'Connecting'); refreshBtn = moduleApi.header.addButton({ icon: ICON_REFRESH, - title: 'Reload Overflow', + title: 'Refresh keywords', onClick: refresh, }); stream = moduleApi.sse('/stream', { diff --git a/modules/overflow-tap/keywords.js b/modules/overflow-tap/keywords.js index 6727b9a..a9a8c49 100644 --- a/modules/overflow-tap/keywords.js +++ b/modules/overflow-tap/keywords.js @@ -1,27 +1,21 @@ 'use strict'; /** - * Keyword mappings — reading the admin page's Keywords text and deciding - * which keyword a slide's notes carry. + * Keyword matching — which of Overflow's keywords a slide's notes carry. * * Pure functions, no I/O, so keywords.selftest.js can pin every rule down: * - * parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }] - * keywordMatches(notes, keyword, mode) → boolean - * matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null - * cleanNotes(raw) → the notes with ProPresenter's invisible padding removed + * cleanNotes(raw) → the notes with ProPresenter's invisible padding removed + * keywordMatches(notes, keyword) → boolean + * matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry + * sameSet(a, b) → the two keyword lists hold the same keywords * - * The Keywords text is one mapping per line: - * - * give = Give Online - * connect -> Connect Card @ Lobby, Kids Wing - * # a comment - * - * `=`, `->` and `→` all separate the keyword from the destination. A trailing - * ` @ ` names the Tap groups this line switches (by name or id); without it the - * admin page's chosen groups apply. The destination is the name Overflow shows - * for it, or its id — resolving that against the account is the server's job, - * so a parse never needs the network. + * The rule is Overflow's own helper's: the notes are split into whitespace- + * separated words, each stripped of surrounding punctuation and lower-cased, + * and a keyword fires when it is one of those words. So `give` fires on + * "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway" + * or "give_now". Phrases work too, bounded the same way on both ends — + * something the helper cannot do, but nothing it would have matched is lost. */ const ZERO_WIDTH = /[​-‍⁠]/g; @@ -34,68 +28,39 @@ function norm(s) { return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); } -function parseKeywordLines(text) { - const out = []; - const seen = new Set(); - cleanNotes(text).split('\n').forEach((raw, i) => { - const line = raw.trim(); - if (!line || line.startsWith('#')) return; - const m = /^(.*?)\s*(?:=|->|→)\s*(.*)$/.exec(line); - const entry = { line: i + 1, keyword: '', destination: '', groups: [], problem: '' }; - if (!m) { - entry.keyword = line; - entry.problem = 'No destination — write it as "keyword = Destination name"'; - out.push(entry); - return; - } - entry.keyword = m[1].trim(); - let rest = m[2].trim(); - const at = rest.indexOf('@'); - if (at >= 0) { - entry.groups = rest.slice(at + 1).split(',').map((s) => s.trim()).filter(Boolean); - rest = rest.slice(0, at).trim(); - } - entry.destination = rest; - if (!entry.keyword) entry.problem = 'No keyword before the ='; - else if (!entry.destination) entry.problem = 'No destination after the ='; - else if (seen.has(norm(entry.keyword))) entry.problem = 'Repeated keyword — the first line wins'; - if (entry.keyword) seen.add(norm(entry.keyword)); - out.push(entry); - }); - return out; -} - function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** - * Does this slide's notes carry the keyword? - * word — the keyword (a word or a phrase) bounded by the start/end of the - * notes or by something that is not a letter, digit or underscore. - * `give` fires on "give" and "give!" but not on "forgiveness". - * anywhere — plain substring, what Overflow's own helper does. - * line — a line of the notes is exactly the keyword. - * All three ignore case and collapse runs of whitespace inside a phrase. - */ -function keywordMatches(notes, keyword, mode = 'word') { +function keywordMatches(notes, keyword) { const n = cleanNotes(notes); const k = norm(keyword); if (!k || !n.trim()) return false; - if (mode === 'line') return n.split('\n').some((l) => norm(l) === k); const flat = n.toLowerCase().replace(/\s+/g, ' '); - if (mode === 'anywhere') return flat.includes(k); const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u'); return re.test(flat); } -/** The first mapping (in admin order) whose keyword the notes carry, or null. */ -function matchKeyword(notes, mappings, mode = 'word') { - for (const m of mappings || []) { - if (!m || !m.keyword) continue; - if (keywordMatches(notes, m.keyword, mode)) return m; +/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */ +function matchKeywords(notes, keywords) { + const out = []; + const seen = new Set(); + for (const raw of keywords || []) { + const k = typeof raw === 'string' ? raw : raw && raw.keyword; + const key = norm(k); + if (!key || seen.has(key)) continue; + if (keywordMatches(notes, k)) { + seen.add(key); + out.push(String(k).trim()); + } } - return null; + return out; } -module.exports = { cleanNotes, norm, parseKeywordLines, keywordMatches, matchKeyword }; +function sameSet(a, b) { + const x = [...(a || [])].map(norm).sort(); + const y = [...(b || [])].map(norm).sort(); + return x.length === y.length && x.every((v, i) => v === y[i]); +} + +module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet }; diff --git a/modules/overflow-tap/keywords.selftest.js b/modules/overflow-tap/keywords.selftest.js index 049dde8..4c71352 100644 --- a/modules/overflow-tap/keywords.selftest.js +++ b/modules/overflow-tap/keywords.selftest.js @@ -1,13 +1,13 @@ 'use strict'; /** - * Self-test for keywords.js — the parsing and matching rules, pinned. + * Self-test for keywords.js — the matching rules, pinned. * * node modules/overflow-tap/keywords.selftest.js */ const assert = require('node:assert/strict'); -const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords'); +const { keywordMatches, matchKeywords, sameSet, cleanNotes } = require('./keywords'); let n = 0; function test(name, fn) { @@ -21,95 +21,56 @@ function test(name, fn) { } } -test('parses keyword = destination, with -> and → too', () => { - const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request'); - assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [ - ['give', 'Give Online', ''], - ['connect', 'Connect Card', ''], - ['prayer', 'Prayer Request', ''], - ]); +test('a keyword fires as a word of the notes, whatever the case', () => { + assert.equal(keywordMatches('give', 'give'), true); + assert.equal(keywordMatches('Give', 'give'), true); + assert.equal(keywordMatches('please GIVE today', 'give'), true); }); -test('a trailing @ names the groups for that line', () => { - const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing'); - assert.equal(m.destination, 'Connect Card'); - assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']); +test('surrounding punctuation is ignored, as the helper strips it', () => { + assert.equal(keywordMatches('give!', 'give'), true); + assert.equal(keywordMatches('#give now', 'give'), true); + assert.equal(keywordMatches('go to (give) now', 'give'), true); + assert.equal(keywordMatches('"give"', 'give'), true); + assert.equal(keywordMatches('give.', 'give'), true); }); -test('blank lines and # comments are skipped, line numbers kept', () => { - const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n'); - assert.equal(m.length, 1); - assert.equal(m[0].line, 4); +test('a keyword inside another word does not fire', () => { + assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false); + assert.equal(keywordMatches('giveaway', 'give'), false); + assert.equal(keywordMatches('give_now', 'give'), false); }); -test('a line without a separator is a problem, not a crash', () => { - const [m] = parseKeywordLines('give'); - assert.equal(m.keyword, 'give'); - assert.match(m.problem, /No destination/); -}); - -test('missing keyword or destination is reported', () => { - const [a, b] = parseKeywordLines('= Give Online\ngive ='); - assert.match(a.problem, /No keyword/); - assert.match(b.problem, /No destination/); -}); - -test('a repeated keyword is flagged and the first line wins', () => { - const m = parseKeywordLines('give = A\nGIVE = B'); - assert.equal(m[0].problem, ''); - assert.match(m[1].problem, /Repeated/); - assert.equal(matchKeyword('give', m).destination, 'A'); -}); - -test('keywords and destinations may be phrases', () => { - const [m] = parseKeywordLines('next steps = Next Steps Card'); - assert.equal(m.keyword, 'next steps'); - assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps', 'word'), true); -}); - -test('whole word: give does not fire on forgiveness, does on "give!"', () => { - assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'word'), false); - assert.equal(keywordMatches('give!', 'give', 'word'), true); - assert.equal(keywordMatches('Give', 'give', 'word'), true); - assert.equal(keywordMatches('#give now', 'give', 'word'), true); - assert.equal(keywordMatches('giveaway', 'give', 'word'), false); - assert.equal(keywordMatches('give_now', 'give', 'word'), false); -}); - -test('anywhere: plain substring, like the Overflow helper', () => { - assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'anywhere'), true); - assert.equal(keywordMatches('nothing here', 'give', 'anywhere'), false); -}); - -test('line: the keyword on its own line only', () => { - assert.equal(keywordMatches('speaker note\ngive\nmore', 'give', 'line'), true); - assert.equal(keywordMatches('give now', 'give', 'line'), false); - assert.equal(keywordMatches(' Give ', 'give', 'line'), true); +test('a phrase keyword is bounded the same way', () => { + assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true); + assert.equal(keywordMatches('nextsteps', 'next steps'), false); }); test('regex characters in a keyword are literal', () => { - assert.equal(keywordMatches('go to (give) now', '(give)', 'word'), true); - assert.equal(keywordMatches('give.now', 'give.now', 'anywhere'), true); - assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false); + assert.equal(keywordMatches('give.now', 'give.now'), true); + assert.equal(keywordMatches('giveXnow', 'give.now'), false); }); test('empty notes never match; zero-width padding is ignored', () => { assert.equal(keywordMatches('', 'give'), false); - assert.equal(keywordMatches('​', 'give', 'anywhere'), false); + assert.equal(keywordMatches('​', 'give'), false); assert.equal(cleanNotes('​give'), 'give'); - assert.equal(keywordMatches('​give', 'give', 'line'), true); + assert.equal(keywordMatches('​give', 'give'), true); }); -test('matchKeyword returns the first mapping in admin order', () => { - const m = parseKeywordLines('connect = Connect Card\ngive = Give Online'); - assert.equal(matchKeyword('give and connect', m).keyword, 'connect'); - assert.equal(matchKeyword('just worship', m), null); - assert.equal(matchKeyword('give', []), null); +test('matchKeywords returns every hit, in list order, once each', () => { + const list = ['connect', 'give', 'prayer', { keyword: 'Events' }]; + assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']); + assert.deepEqual(matchKeywords('just worship', list), []); + assert.deepEqual(matchKeywords('give', []), []); + assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']); }); -test('a mapping with a parse problem can still be the match (the caller decides)', () => { - const m = parseKeywordLines('baptism'); - assert.equal(matchKeyword('baptism', m).problem.length > 0, true); +test('sameSet compares keyword lists regardless of order and case', () => { + assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true); + assert.equal(sameSet(['give'], ['give', 'connect']), false); + assert.equal(sameSet([], []), true); + assert.equal(sameSet(['give'], []), false); }); console.log(`${n} tests, exit ${process.exitCode || 0}`); diff --git a/modules/overflow-tap/module.json b/modules/overflow-tap/module.json index 4fec966..2c2ff78 100644 --- a/modules/overflow-tap/module.json +++ b/modules/overflow-tap/module.json @@ -23,14 +23,11 @@ }, "configGroups": { "ProPresenter": { - "help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes are read — nothing on the stage screens changes." + "help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes (and the announcement layer's) are read — nothing on the stage screens changes." }, "Overflow": { "collapsed": "whenSet", - "help": "API credentials from the Overflow Dashboard: Developers › API Keys (a READ_WRITE key). Tap groups and destinations are read from the account once these are saved; the groups list below fills in from there." - }, - "Keywords": { - "help": "One keyword per line, as `keyword = Destination name` — the destination exactly as it is named in Overflow's Tap module (or its id). Add ` @ Group A, Group B` to a line to switch only those groups instead of the ones chosen above. When a slide's notes carry one of these keywords, every chosen group is pointed at that destination; a slide without a keyword changes nothing. When several match, the first line wins." + "help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes." }, "Alerts": { "toggle": "alertEnabled", @@ -55,53 +52,24 @@ "default": "", "help": "Only if a password is set in ProPresenter's network preferences." }, - "clientId": { + "overflowEmail": { "type": "string", - "label": "Client ID", + "label": "Email", "group": "Overflow", "default": "" }, - "apiKey": { + "overflowPassword": { "type": "password", - "label": "API key", + "label": "Password", "group": "Overflow", "default": "" }, - "groups": { - "type": "multiselect", - "label": "Tap groups", + "nonprofitId": { + "type": "string", + "label": "Nonprofit ID", "group": "Overflow", - "default": [], - "optionsRoute": "/groups", - "help": "The groups a keyword switches. None chosen means every group on the account." - }, - "keywords": { - "type": "text", - "label": "Keywords", - "group": "Keywords", "default": "", - "help": "One per line — give = Give Online" - }, - "matching": { - "type": "select", - "label": "Match", - "group": "Keywords", - "default": "word", - "options": [ - { - "value": "word", - "label": "Whole word in the notes" - }, - { - "value": "anywhere", - "label": "Anywhere in the notes" - }, - { - "value": "line", - "label": "On its own line" - } - ], - "help": "Whole word keeps `give` from firing on `forgiveness`; anywhere is what Overflow's own helper does." + "help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one." }, "alertEnabled": { "type": "switch", diff --git a/modules/overflow-tap/overflow.js b/modules/overflow-tap/overflow.js index 346df7c..d2eb9e3 100644 --- a/modules/overflow-tap/overflow.js +++ b/modules/overflow-tap/overflow.js @@ -1,78 +1,104 @@ 'use strict'; /** - * Overflow API client — the Tap part of Overflow's public REST API. + * Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration" + * helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is + * in Overflow's public API reference (which documents only reads for Tap): it + * is the Dashboard's own backend, used with a Dashboard login — the same + * email and password the helper asks for. Google sign-in does not work here; + * the account needs a password (Overflow's article says the same). * - * https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json) + * POST /nonprofit-dashboard/authentication/login/jwt + * { email, password } → { token } a JWT; its payload carries + * email, nonprofitId, permissions [{ action, subject }] (subject TAP + * or all is required), nonprofits [...], exp + * GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords + * → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ] + * the Dashboard's Integrations › ProPresenter › Keyword Mappings + * POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true + * { keywords: ["give"] } → the same list, updated THE SWITCH: + * Overflow points the keyword's groups at its destination + * GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit= + * POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select + * only for an @overflow.co staff login, which has no nonprofit of its + * own and must pick one first * - * Authentication is two headers, `x-client-id` and `x-api-key`, from the - * Overflow Dashboard's Developers › API Keys. 120 requests per minute per - * client; a 429 is answered when the window is spent. + * Bearer auth on everything after login. A 401 means the session is gone — + * sign in again. The helper's own wording for a refused login is kept here + * (401 invalid email or password, 429 too many attempts, 403 locked). * - * Reads used here are documented: GET /api/v3/tap/destinations, - * GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the - * `destinationId` it currently points at). - * - * THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only - * for Tap; Overflow's own ProPresenter helper changes a group's destination - * through the Dashboard's private API after signing in with a Dashboard - * login. `setGroupDestination` below sends the natural REST shape for this - * API — PATCH the group with the new `destinationId`, as PATCH /donors/{id} - * updates a donor — and the server confirms every switch by reading the group - * back, so an answer that changes nothing is reported, not believed. If - * Overflow publishes (or confirms) a different route, this one function is - * the only place to change. tools/overflow-tap-mock.js serves the same shape. - * - * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock. + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at + * tools/overflow-tap-mock.js. */ const DEFAULT_API_BASE = 'https://server.overflow.co'; const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); +const DASH = '/nonprofit-dashboard'; const TIMEOUT_MS = 10000; -const PAGE_SIZE = 100; -const MAX_PAGES = 20; -const ID_RE = /^[0-9a-f]{24}$/i; +const TRIGGER_TIMEOUT_MS = 15000; class OverflowError extends Error { - constructor(message, { status = 0, retryable = false } = {}) { + constructor(message, { status = 0, retryable = false, auth = false } = {}) { super(message); this.name = 'OverflowError'; this.status = status; this.retryable = retryable; + this.auth = auth; // the session was refused: sign in again before retrying } } -function looksLikeId(s) { - return ID_RE.test(String(s || '').trim()); +/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */ +function decodeJwt(token) { + const parts = String(token || '').split('.'); + if (parts.length !== 3) return null; + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); + } catch { + return null; + } } -function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) { - const id = String(clientId || '').trim(); - const key = String(apiKey || '').trim(); +/** What the module needs to know about a session, from its token. */ +function describeSession(token) { + const p = decodeJwt(token) || {}; + const email = String(p.email || ''); + const perms = Array.isArray(p.permissions) ? p.permissions : []; + const nonprofits = Array.isArray(p.nonprofits) + ? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id) + : []; + return { + email, + staff: email.toLowerCase().endsWith('@overflow.co'), + nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '', + nonprofits, + hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')), + expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0, + }; +} - async function request(method, path, { query, body } = {}) { - if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 }); +function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) { + async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) { const url = new URL(API_BASE + path); for (const [k, v] of Object.entries(query || {})) { if (v === undefined || v === null || v === '') continue; url.searchParams.set(k, String(v)); } - const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key }; + const headers = { Accept: 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; if (body !== undefined) headers['Content-Type'] = 'application/json'; const ctl = new AbortController(); - const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); + const timer = setTimeout(() => ctl.abort(), timeout); let res; try { - res = await fetchImpl(url, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: ctl.signal, - }); + res = await fetchImpl(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctl.signal }); } catch (err) { const aborted = err && err.name === 'AbortError'; const cause = err && err.cause && (err.cause.code || err.cause.message); - throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true }); + throw new OverflowError( + aborted ? `Overflow did not answer within ${timeout / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, + { retryable: true }, + ); } finally { clearTimeout(timer); } @@ -83,44 +109,66 @@ function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch } } if (!res.ok) { const detail = data && (data.message || data.error || data.detail); - const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText); - let message; - if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`; - else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`; - else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)'; - else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`; - throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 }); + const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : ''); + throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), { + status: res.status, + auth: res.status === 401, + retryable: res.status === 429 || res.status >= 500, + }); } return data; } - /** Every page of a collection, newest-first as the API sorts it. */ - async function listAll(path, query = {}) { - const all = []; - for (let page = 1; page <= MAX_PAGES; page += 1) { - const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } }); - const data = Array.isArray(res && res.data) ? res.data : []; - all.push(...data); - const total = Number(res && res.totalCount); - if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break; - } - return all; - } - return { base: API_BASE, - configured: Boolean(id && key), - listDestinations: () => listAll('/api/v3/tap/destinations'), - listGroups: () => listAll('/api/v3/tap/groups'), - async getGroup(groupId) { - const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`); - return res && res.data ? res.data : res; + + /** Sign in with a Dashboard login; resolves to the JWT. */ + async login(email, password) { + try { + const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } }); + const token = data && data.token; + if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token'); + return token; + } catch (err) { + if (!(err instanceof OverflowError) || !err.status) throw err; + if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 }); + if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true }); + if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 }); + throw err; + } }, - /** See the note at the top of this file: the one unverified call. */ - setGroupDestination(groupId, destinationId) { - return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } }); + + /** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */ + async listKeywords(token, nonprofitId) { + const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token }); + return Array.isArray(data) ? data : []; + }, + + /** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */ + async trigger(token, nonprofitId, keywords) { + const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { + query: { syncNonprofit: 'true' }, + body: { keywords }, + token, + timeout: TRIGGER_TIMEOUT_MS, + }); + return Array.isArray(data) ? data : null; + }, + + /** Staff logins only: the nonprofits this login may act for. */ + async searchNonprofits(token, search = '', limit = 10) { + const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } }); + const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data; + return Array.isArray(list) + ? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') })) + : []; + }, + + /** Staff logins only: bind the session to one nonprofit before reading its keywords. */ + selectNonprofit(token, nonprofitId) { + return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} }); }, }; } -module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE }; +module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE }; diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js index 149f3ff..3036a76 100644 --- a/modules/overflow-tap/server.js +++ b/modules/overflow-tap/server.js @@ -4,48 +4,59 @@ * Overflow Tap — server entry. * * Does what Overflow's "ProPresenter Integration" helper app does, inside - * ProdDash: watches the live slide in ProPresenter, and when its notes carry - * one of the admin page's keywords, points the chosen Overflow Tap groups at - * that keyword's destination — so what people reach when they tap a disc - * follows what is on screen. A slide with no keyword changes nothing; the - * last destination stays until the next keyworded slide. + * ProdDash: watches the live slide in ProPresenter (the presentation layer + * and the announcement layer), and when its notes carry one of the keywords + * mapped in the Overflow Dashboard, tells Overflow — which points that + * keyword's Tap groups at its destination, so what people reach when they + * tap a disc follows what is on screen. A slide with no keyword changes + * nothing; the last destination stays until the next keyworded slide. * - * GET /state → the view-model every tile draws - * GET /stream → SSE, `state` events on every change (heartbeated) - * POST /refresh → re-read Overflow's groups and destinations now - * GET /groups → optionsRoute for the admin page's Tap groups picker - * GET /destinations → the destinations, for a quick look while setting up + * GET /state → the view-model every tile draws + * GET /stream → SSE, `state` events on every change (heartbeated) + * POST /refresh → re-read the keyword mappings from the Dashboard now + * GET /keywords → the mappings as read, for a look while setting up * - * ProPresenter is polled (GET /v1/status/slide, twice a second — one small - * request, nothing heavier) because its chunked update stream is the thing - * that goes quiet without saying so. Overflow is read on start, every few - * minutes after that, and on /refresh; every switch is confirmed by reading - * the group back. Which Overflow call does the switching, and why that call - * is the one unverified piece, is explained at the top of overflow.js. + * ProPresenter is polled (GET /v1/status/slide for the presentation layer, + * GET /v1/announcement/slide_index for announcements, once a second each — + * small requests, nothing heavier) because its chunked update stream is the + * thing that goes quiet without saying so. Overflow is the Dashboard's own + * API, signed in with a Dashboard login — the calls, and where they come + * from, are at the top of overflow.js. Keyword mappings are read on start, + * every few minutes after that, on /refresh, and from every trigger's answer. * * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602. */ -const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow'); -const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords'); +const crypto = require('crypto'); +const { createOverflowClient, OverflowError, describeSession } = require('./overflow'); +const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords'); -const POLL_MS = 500; // ProPresenter, while it answers -const RETRY_MS = 3000; // ProPresenter, while it does not +const POLL_MS = 1000; // ProPresenter, while it answers +const RETRY_MS = 3000; // ProPresenter, while it does not const PP_TIMEOUT_MS = 4000; const HEARTBEAT_MS = 15000; -const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000; -const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed +const KEYWORDS_REFRESH_MS = 5 * 60 * 1000; const OVERFLOW_RETRY_MS = 30 * 1000; +const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts") +const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires const SWITCH_RETRY_MS = 10 * 1000; const SWITCH_RETRIES = 3; const HISTORY_MAX = 8; // the tile shows four; a few more for the hover const ALERT_SECONDS = 30; -const MATCH_MODES = new Set(['word', 'anywhere', 'line']); /** Set by init(), read by routes() — both are rebuilt together on a config save. */ let current = null; +/** + * The signed-in session, kept across re-inits for the same login: an admin + * saving config five times in a minute must not become five sign-ins + * (Overflow refuses that with 429). Keyed on a hash of the login, so a + * changed email or password starts fresh; the token itself never leaves the + * server process. + */ +let sessionCache = null; // { key, token, expiresAt, email, nonprofitId } + function json(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); @@ -60,20 +71,43 @@ function describe(err) { return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg; } +function unique(list) { + const seen = new Set(); + const out = []; + for (const v of list) { + const k = norm(v); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(v); + } + return out; +} + +/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */ +function destinationOf(entry) { + return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || ''); +} +function groupsOf(entry) { + const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : []; + return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean); +} + module.exports = { init({ config, log, shell }) { /* ── config ────────────────────────────────────────────────────── */ const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; const host = String(ep.host || '').trim(); const port = Number(ep.port) || 1025; - const password = String(config.password || ''); - const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean); - const mappings = parseKeywordLines(config.keywords); - const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word'; + const ppPassword = String(config.password || ''); + const email = String(config.overflowEmail || '').trim(); + const ofPassword = String(config.overflowPassword || ''); + const staffNonprofit = String(config.nonprofitId || '').trim(); + const configured = Boolean(email && ofPassword); const alertEnabled = Boolean(config.alertEnabled); const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1))); + const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex'); - const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey }); + const overflow = createOverflowClient(); /* ── state ─────────────────────────────────────────────────────── */ const streams = new Set(); @@ -81,23 +115,35 @@ module.exports = { let stopped = false; let lastFrame = ''; - let destinations = []; // Overflow's, as listed - let groups = []; // Overflow's, as listed (each with its current destinationId) - let resolved = []; // the mappings, resolved against those two lists - let lastSlideKey = null; // change detection on the live slide - let wanted = null; // the resolved keyword Tap should be on + let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry + let session = null; // { token, expiresAt, email, nonprofitId } + let lastLoginAt = 0; + let lastLoginError = null; + let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups } let inFlight = false; let retriesLeft = 0; let retryTimer = null; let alertRaised = false; + // Presentation layer: the slide /v1/status/slide reports. + let lastSlideKey = null; + let lastSlide = null; + let lastPresKeywords = []; + // Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active. + let annSupported = true; + let lastAnnKey = null; + let annNotes = ''; + let annLive = false; + let lastAnnKeywords = []; + const state = { status: 'connecting', message: '', propresenter: { configured: Boolean(host), reachable: false, error: '', host, port }, - overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 }, + overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 }, keywords: [], - slide: { live: false, keyword: '', unmapped: '', hasNotes: false }, + slide: { live: false, keywords: [], hasNotes: false }, + announcement: { live: false, keywords: [] }, current: null, history: [], updatedAt: 0, @@ -117,30 +163,26 @@ module.exports = { /* ── health, shared by the admin line and the tiles ────────────── */ - function assess() { + function assess(forAdmin = false) { const pp = state.propresenter; const of = state.overflow; if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin']; - if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin']; - if (!mappings.length) return ['error', 'No keywords configured — add some in /admin']; - if (!of.reachable) return ['error', of.error || 'Reaching Overflow…']; + if (!of.configured) return ['error', 'Overflow login not set — add the email and password in /admin']; + if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…']; if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; - const bad = state.keywords.filter((k) => k.problem); - if (bad.length && bad.length === state.keywords.length) { - return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`]; - } + if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)']; if (state.current && !state.current.pending && !state.current.ok) { return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; } if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; - const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : ''; - const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : ''; - return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`]; + const n = state.keywords.length; + const who = forAdmin && session && session.email ? `${session.email}, ` : ''; + return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`]; } function publish() { if (stopped) return; - [state.status, state.message] = assess(); + [state.status, state.message] = assess(false); const { updatedAt, ...rest } = state; let frame; try { @@ -161,103 +203,106 @@ module.exports = { } } - /* ── Overflow: destinations, groups, and the keywords resolved ─── */ + /* ── the Overflow session ──────────────────────────────────────── */ - function resolveKeywords() { - const dById = new Map(destinations.map((d) => [String(d.id), d])); - const dByName = new Map(destinations.map((d) => [norm(d.name), d])); - const gById = new Map(groups.map((g) => [String(g.id), g])); - const gByName = new Map(groups.map((g) => [norm(g.name), g])); - const defaults = chosenGroups.length - ? chosenGroups.map((id) => gById.get(id)).filter(Boolean) - : groups.slice(); - resolved = mappings.map((m) => { - const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem }; - if (out.problem) return out; - const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination)); - if (!d) { - out.problem = destinations.length - ? `No Overflow destination named "${m.destination}"` - : 'Overflow destinations not read yet'; - return out; - } - out.destination = String(d.name || m.destination); - out.destinationId = String(d.id); - let gs; - if (m.groups.length) { - gs = []; - const missing = []; - for (const name of m.groups) { - const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name)); - if (g) gs.push(g); - else missing.push(name); - } - if (missing.length) { - out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`; - return out; - } - } else { - gs = defaults; - } - if (!gs.length) { - out.problem = groups.length - ? 'None of the chosen Tap groups exist any more' - : 'No Tap groups on this Overflow account'; - return out; - } - out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) })); - return out; - }); - state.keywords = resolved.map((r) => ({ - keyword: r.keyword, - destination: r.destination, - groups: r.groups.map((g) => g.name), - problem: r.problem, - })); - } - - /** Has anyone moved a group we switched? Noted on the live entry, never fought. */ - function noteDrift() { - const cur = state.current; - if (!cur || !cur.ok || cur.pending) return; - const byId = new Map(groups.map((g) => [String(g.id), g])); - const moved = []; - for (const g of cur.groupIds || []) { - const now = byId.get(g); - if (!now) continue; - if (String(now.destinationId || '') !== cur.destination.id) { - moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`); - } + async function ensureSession() { + const now = Date.now(); + if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session; + if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) { + session = sessionCache; + return session; } - cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : ''; + if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError; + lastLoginAt = now; + let token; + try { + token = await overflow.login(email, ofPassword); + } catch (err) { + lastLoginError = err; + throw err; + } + lastLoginError = null; + const info = describeSession(token); + if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role'); + let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : ''); + if (info.staff) { + if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin'); + await overflow.selectNonprofit(token, staffNonprofit); + nonprofitId = staffNonprofit; + } else if (!nonprofitId) { + nonprofitId = staffNonprofit; + } + if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin'); + session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId }; + sessionCache = { key: sessionKey, ...session }; + log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`); + return session; } - let destinationsTimer = null; - let groupsTimer = null; - let loading = null; + function dropSession() { + session = null; + if (sessionCache && sessionCache.key === sessionKey) sessionCache = null; + } - async function loadOverflow(what = 'all') { - if (stopped || !overflow.configured) return; + /** Run a signed-in call; when the session is refused, sign in again once and retry. */ + async function withSession(fn) { + const s = await ensureSession(); + try { + return await fn(s); + } catch (err) { + if (!(err instanceof OverflowError) || !err.auth) throw err; + dropSession(); + const again = await ensureSession(); + return fn(again); + } + } + + /* ── the keyword mappings ──────────────────────────────────────── */ + + function rebuildKeywordView() { + state.keywords = [...keywordCache.values()].map((e) => ({ + keyword: String(e.keyword).trim(), + destination: destinationOf(e), + groups: groupsOf(e), + })); + state.overflow.keywords = state.keywords.length; + } + + /** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */ + function applyKeywordList(list, merge = false) { + const next = merge ? new Map(keywordCache) : new Map(); + for (const entry of list || []) { + if (!entry || typeof entry !== 'object') continue; + const k = norm(entry.keyword); + if (!k) continue; + if (entry.active === false || entry.enabled === false || entry.isActive === false) { + next.delete(k); + continue; + } + next.set(k, entry); + } + keywordCache = next; + rebuildKeywordView(); + } + + let loading = null; + let refreshTimer = null; + + async function loadKeywords() { + if (stopped || !configured) return; if (loading) return loading; loading = (async () => { try { - if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations(); - groups = await overflow.listGroups(); + const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId)); + applyKeywordList(list); state.overflow.reachable = true; state.overflow.error = ''; - state.overflow.groups = groups.length; - state.overflow.destinations = destinations.length; state.overflow.loadedAt = Date.now(); - resolveKeywords(); - noteDrift(); - // A slide that was already up when the lists arrived (or when a - // destination got its name fixed in Overflow) should take effect now. - if (lastSlideKey !== null) reconsiderSlide(); + reconsider(); // a slide already up when the mappings arrive takes effect now } catch (err) { state.overflow.reachable = false; state.overflow.error = describe(err); log(`Overflow: ${state.overflow.error}`); - if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines } publish(); })(); @@ -266,24 +311,28 @@ module.exports = { } finally { loading = null; } - scheduleOverflow(); - } - - function scheduleOverflow() { if (stopped) return; - if (destinationsTimer) clearTimeout(destinationsTimer); - if (groupsTimer) clearTimeout(groupsTimer); - timers.delete(destinationsTimer); - timers.delete(groupsTimer); - const ok = state.overflow.reachable; - destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS); - groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null; + if (refreshTimer) { + clearTimeout(refreshTimer); + timers.delete(refreshTimer); + } + refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS); } /* ── the switch ────────────────────────────────────────────────── */ + function describeTarget(keys) { + const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean); + return { + keywords: keys, + keyword: keys.join(' + '), + destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '), + groups: unique(entries.flatMap(groupsOf)), + }; + } + function pushHistory(entry) { - const { pending, drift, ...rest } = entry; + const { pending, ...rest } = entry; state.history.unshift(rest); if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; } @@ -316,7 +365,7 @@ module.exports = { if (stopped || inFlight || !wanted) return; const target = wanted; const cur = state.current; - const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId; + const sameTarget = cur && sameSet(cur.keywords, target.keywords); if (sameTarget && cur.ok) return; // already there inFlight = true; let entry; @@ -326,15 +375,14 @@ module.exports = { if (cur) pushHistory(cur); entry = { keyword: target.keyword, - destination: { id: target.destinationId, name: target.destination }, - groups: target.groups.map((g) => g.name), - groupIds: target.groups.map((g) => g.id), + keywords: target.keywords, + destination: target.destination, + groups: target.groups, at: Date.now(), ok: false, pending: true, error: '', attempts: 0, - drift: '', }; state.current = entry; } @@ -342,32 +390,23 @@ module.exports = { entry.attempts += 1; publish(); try { - for (const g of target.groups) { - if (stopped) return; - await overflow.setGroupDestination(g.id, target.destinationId); - const check = await overflow.getGroup(g.id); - const nowAt = check ? String(check.destinationId || '') : ''; - if (check && nowAt !== target.destinationId) { - const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination'; - throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 }); - } - const cached = groups.find((x) => String(x.id) === g.id); - if (cached) { - cached.destinationId = target.destinationId; - cached.destination = { id: target.destinationId, name: target.destination }; - } + const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords)); + if (answer) { + applyKeywordList(answer, true); + const fresh = describeTarget(target.keywords); + entry.destination = fresh.destination || entry.destination; + entry.groups = fresh.groups.length ? fresh.groups : entry.groups; } entry.ok = true; entry.error = ''; entry.at = Date.now(); - entry.drift = ''; clearAlert(); - log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`); + log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`); } catch (err) { if (stopped) return; entry.ok = false; entry.error = describe(err); - log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`); + log(`Tap not switched to ${target.keyword}: ${entry.error}`); if (retriesLeft > 0) { retriesLeft -= 1; retryTimer = later(() => { @@ -384,11 +423,38 @@ module.exports = { } } - /* ── ProPresenter: the live slide ──────────────────────────────── */ + /* ── what is on screen, and what to do about it ────────────────── */ + + /** + * The helper's rule, per layer: when the set of keywords the layer's + * slide carries changes to a non-empty set, send it. Returning to a + * keyword after a slide without one sends it again; staying on the same + * keyword across slides sends nothing. + */ + function reconsider() { + const list = [...keywordCache.values()]; + const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : ''; + const presKeys = lastSlide ? matchKeywords(presNotes, list) : []; + state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) }; + const presChanged = !sameSet(presKeys, lastPresKeywords); + if (presChanged) lastPresKeywords = presKeys; + + const annKeys = annLive ? matchKeywords(annNotes, list) : []; + state.announcement = { live: annLive, keywords: annKeys }; + const annChanged = !sameSet(annKeys, lastAnnKeywords); + if (annChanged) lastAnnKeywords = annKeys; + + const batch = []; + if (presChanged && presKeys.length) batch.push(...presKeys); + if (annChanged && annKeys.length) batch.push(...annKeys); + if (batch.length) requestSwitch(describeTarget(unique(batch))); + } + + /* ── ProPresenter ──────────────────────────────────────────────── */ function ppUrl(target) { const u = new URL(`http://${host}:${port}${target}`); - if (password) u.searchParams.set('password', password); + if (ppPassword) u.searchParams.set('password', ppPassword); return u; } @@ -397,7 +463,11 @@ module.exports = { const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS); try { const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal }); - if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`); + if (!res.ok) { + const err = new Error(`ProPresenter ${res.status} ${res.statusText}`); + err.status = res.status; + throw err; + } const text = await res.text(); return text ? JSON.parse(text) : null; } finally { @@ -405,62 +475,96 @@ module.exports = { } } - let lastSlide = null; - - function reconsiderSlide() { - const cur = lastSlide; - if (!cur) { - state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false }; - return; - } - const notes = cleanNotes(cur.notes); - const hit = matchKeyword(notes, resolved, mode); - state.slide = { - live: true, - keyword: hit ? hit.keyword : '', - unmapped: hit && hit.problem ? hit.problem : '', - hasNotes: Boolean(notes.trim()), - }; - if (hit && !hit.problem) requestSwitch(hit); + /** The presentation layer: /v1/status/slide names the live slide and carries its notes. */ + async function pollSlide() { + const data = await ppJson('/v1/status/slide'); + const cur = data && data.current && typeof data.current === 'object' ? data.current : null; + const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; + if (key === lastSlideKey) return false; + lastSlideKey = key; + lastSlide = cur; + return true; } - async function pollSlide() { + /** + * The announcement layer: its slide index names the document and the + * slide; the document's slides (every group's, in order, disabled ones + * included — that is the index's space) carry the notes. An older + * ProPresenter without this API answers 404 once and is left alone. + */ + async function pollAnnouncement() { + if (!annSupported) return false; + let idx; + try { + idx = await ppJson('/v1/announcement/slide_index'); + } catch (err) { + if (err && err.status === 404) { + annSupported = false; + log('this ProPresenter has no announcement layer API — following the presentation layer only'); + return false; + } + throw err; + } + const node = idx && idx.announcement_index; + if (!node || !Number.isInteger(node.index)) { + if (lastAnnKey === '') return false; + lastAnnKey = ''; + annLive = false; + annNotes = ''; + return true; + } + const id = node.presentation_id || {}; + const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`; + if (key === lastAnnKey) return false; + const doc = await ppJson('/v1/announcement/active'); + const ann = doc && doc.announcement; + const slides = []; + for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) { + for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s); + } + const slide = slides[node.index]; + annNotes = slide ? cleanNotes(slide.notes) : ''; + annLive = true; + lastAnnKey = key; + return true; + } + + async function pollTick() { if (stopped) return; let ok = false; + let changed = false; try { - const data = await ppJson('/v1/status/slide'); - const cur = data && data.current && typeof data.current === 'object' ? data.current : null; + changed = await pollSlide(); state.propresenter.reachable = true; state.propresenter.error = ''; ok = true; - const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; - if (key !== lastSlideKey) { - lastSlideKey = key; - lastSlide = cur; - reconsiderSlide(); + try { + if (await pollAnnouncement()) changed = true; + } catch { + /* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */ } } catch (err) { state.propresenter.reachable = false; state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err); } + if (changed) reconsider(); publish(); - later(pollSlide, ok ? POLL_MS : RETRY_MS); + later(pollTick, ok ? POLL_MS : RETRY_MS); } /* ── go ────────────────────────────────────────────────────────── */ - resolveKeywords(); if (host) { - pollSlide(); + pollTick(); log(`following ProPresenter at ${host}:${port}`); } else { log('no ProPresenter host configured — set one in /admin'); } - if (overflow.configured) { - loadOverflow('all'); + if (configured) { + loadKeywords(); log(`Overflow at ${overflow.base}`); } else { - log('Overflow client ID / API key not set — add them in /admin'); + log('Overflow login not set — add the email and password in /admin'); } publish(); @@ -477,11 +581,9 @@ module.exports = { const handle = { streams, + configured, getState: () => state, - refresh: () => loadOverflow('all'), - groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })), - destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })), - configured: overflow.configured, + refresh: () => loadKeywords(), }; current = handle; @@ -499,7 +601,7 @@ module.exports = { if (current === handle) current = null; }, health() { - const [status, message] = assess(); + const [status, message] = assess(true); return { status, message }; }, }; @@ -529,23 +631,17 @@ module.exports = { 'POST /refresh': async (req, res) => { if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); - if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' }); + if (!current.configured) return json(res, 400, { error: 'Overflow login is not set.' }); await current.refresh(); const st = current.getState(); if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' }); - json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations }); + json(res, 200, { ok: true, keywords: st.overflow.keywords }); }, - 'GET /groups': async (req, res) => { - if (!current) return json(res, 200, { options: [] }); - if (current.configured && !current.groupOptions().length) await current.refresh(); - json(res, 200, { options: current.groupOptions() }); - }, - - 'GET /destinations': async (req, res) => { - if (!current) return json(res, 200, { options: [] }); - if (current.configured && !current.destinationOptions().length) await current.refresh(); - json(res, 200, { options: current.destinationOptions() }); + 'GET /keywords': (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + const st = current.getState(); + json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error }); }, }; }, diff --git a/tools/overflow-tap-mock.js b/tools/overflow-tap-mock.js index 024c4f4..fa1d54b 100644 --- a/tools/overflow-tap-mock.js +++ b/tools/overflow-tap-mock.js @@ -1,46 +1,55 @@ /** - * Mock ProPresenter slide status + mock Overflow Tap API, for developing the - * overflow-tap module without a live ProPresenter or an Overflow account. + * Mock ProPresenter slide status + mock Overflow Dashboard API, for developing + * the overflow-tap module without a live ProPresenter or an Overflow account. * * node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000] - * [--key=] [--read-only] [--slow] + * [--login=:] [--expire=] + * [--fail-switch] [--slow] [--no-announcements] * - * ProPresenter side (127.0.0.1:1602 by default) — the one route the module - * uses, with a service that advances every --step ms: - * GET /version reachability probe - * GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling - * through slides whose notes carry keywords - * ("give", "Next steps: connect", "prayer"), slides - * with other notes ("forgiveness" — must NOT fire - * "give" in whole-word mode), a slide whose keyword - * maps to nothing on the account, and a stretch with - * nothing live. + * ProPresenter side (127.0.0.1:1602 by default) — the routes the module uses, + * with a service that advances every --step ms: + * GET /version reachability probe + * GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling + * through slides whose notes carry keywords + * ("give", "Next steps: connect", "prayer"), + * slides with other notes ("forgiveness" — + * must NOT fire "give"), a slide whose + * keyword is not mapped on the account, and + * a stretch with nothing live. + * GET /v1/announcement/slide_index the announcement layer: a three-slide + * document whose index advances every third + * step; its middle slide carries "events". + * GET /v1/announcement/active that document, groups → slides → notes. + * --no-announcements answers 404 to both, + * like an older ProPresenter. * * Overflow side (127.0.0.1:24720 by default; run ProdDash with - * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with - * paging, and the switch the module sends: - * GET /api/v3/tap/destinations four destinations - * GET /api/v3/tap/groups three groups, each pointing somewhere - * GET /api/v3/tap/groups/{id} - * PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group. - * --read-only answers 404 instead, which - * is what Overflow's public API does - * today, to exercise the failure path. - * Every request must carry x-client-id and x-api-key (any non-empty values; - * --key= demands that exact key, answering 401 otherwise). --slow adds - * a two-second delay to every Overflow answer. - * - * Suggested admin Keywords for this mock: - * give = Give Online - * connect = Connect Card @ Lobby - * prayer = Prayer Request - * baptism = Baptism Signup + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the + * module speaks (see modules/overflow-tap/overflow.js): + * POST /nonprofit-dashboard/authentication/login/jwt any non-empty email + + * password signs in (--login=: demands that pair, + * answering 401 otherwise); the token expires after --expire seconds + * (3600 by default — set 20 to exercise the sign-in-again path). An + * @overflow.co email gets a staff token with no nonprofit of its own. + * GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits + * POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select + * GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings: + * give → Give Online (every group), connect → Connect Card (Lobby), + * prayer → Prayer Request (every group), events → Events (Lobby, Kids + * Wing). "baptism" is deliberately unmapped. + * POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true + * { keywords } — moves the groups and answers the updated list. + * --fail-switch answers 500 instead, to exercise the failure path. + * Every signed-in call needs a Bearer token this mock issued and has not + * expired, or it answers 401. --slow adds a two-second delay to every Overflow + * answer. * * Every incoming request is logged so we can confirm what the module does. */ 'use strict'; const http = require('http'); +const crypto = require('crypto'); const args = process.argv.slice(2); const opt = (name, dflt) => { @@ -50,9 +59,16 @@ const opt = (name, dflt) => { const PP_PORT = Number.parseInt(opt('pp', '1602'), 10); const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10); const STEP_MS = Number.parseInt(opt('step', '4000'), 10); -const KEY = opt('key', ''); -const READ_ONLY = args.includes('--read-only'); +const LOGIN = opt('login', ''); +const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10); +const FAIL_SWITCH = args.includes('--fail-switch'); const SLOW = args.includes('--slow'); +const NO_ANN = args.includes('--no-announcements'); + +function sendJson(res, code, body, extra = {}) { + res.writeHead(code, { 'Content-Type': 'application/json', ...extra }); + res.end(body === undefined ? '' : JSON.stringify(body)); +} /* ── ProPresenter ────────────────────────────────────────────────── */ @@ -68,116 +84,172 @@ const SLIDES = [ { uuid: 'S8', text: 'Give online (again)', notes: 'give' }, { uuid: 'S9', text: 'See you next week', notes: '' }, ]; -let slideIdx = 0; -setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref(); +const ANNOUNCEMENT = { + id: { uuid: 'ANN-1', name: 'Announcements', index: 0 }, + presentation_path: 'Announcements', + groups: [ + { name: 'Loop', slides: [ + { enabled: true, notes: '', text: 'Welcome', label: '' }, + { enabled: true, notes: 'events', text: 'Upcoming events', label: '' }, + { enabled: true, notes: '', text: 'Thanks for coming', label: '' }, + ] }, + ], +}; +let step = 0; +setInterval(() => { step += 1; }, STEP_MS).unref(); +const slideIdx = () => step % SLIDES.length; +const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length; function slidePayload() { - const cur = SLIDES[slideIdx]; - const nxt = SLIDES[(slideIdx + 1) % SLIDES.length]; + const cur = SLIDES[slideIdx()]; + const nxt = SLIDES[(slideIdx() + 1) % SLIDES.length]; return { current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null, next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null, }; } -function sendJson(res, code, body, extra = {}) { - res.writeHead(code, { 'Content-Type': 'application/json', ...extra }); - res.end(JSON.stringify(body)); -} - http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const p = url.pathname; console.log(`[pp] ${req.method} ${p}`); if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' }); if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload()); - if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx]), media: false, announcements: false }); + if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx()]), media: false, announcements: !NO_ANN }); + if (p === '/v1/announcement/slide_index') { + if (NO_ANN) return sendJson(res, 404, { error: 'not found' }); + return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } }); + } + if (p === '/v1/announcement/active') { + if (NO_ANN) return sendJson(res, 404, { error: 'not found' }); + return sendJson(res, 200, { announcement: ANNOUNCEMENT }); + } sendJson(res, 404, { error: 'not found' }); }).listen(PP_PORT, '127.0.0.1', () => { - console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms)`); + console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms${NO_ANN ? ', no announcement layer' : ''})`); }); -/* ── Overflow ────────────────────────────────────────────────────── */ - -const stamp = (d) => new Date(d).toISOString(); -const DESTINATIONS = [ - { id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null }, - { id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null }, - { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null }, - { id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' }, -].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); +/* ── Overflow Dashboard ──────────────────────────────────────────── */ +const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' }; +const DESTINATIONS = { + give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' }, + connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' }, + prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' }, + events: { id: '6710f34fd5061afeec3eab04', name: 'Events' }, +}; const GROUPS = [ - { id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] }, - { id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] }, - { id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] }, -].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); + { id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id }, + { id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id }, + { id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id }, +]; +const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName }); +const KEYWORDS = [ + { id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true }, + { id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true }, + { id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true }, + { id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true }, +]; +const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() })); -function destView(id) { - const d = DESTINATIONS.find((x) => x.id === id); - return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null; -} -function groupView(g) { - return { ...g, destination: destView(g.destinationId) }; -} -function page(list, url) { - const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25)); - const pg = Math.max(1, Number(url.searchParams.get('page')) || 1); - const start = (pg - 1) * limit; - return { data: list.slice(start, start + limit), totalCount: list.length }; +const tokens = new Map(); // token → { email, exp, nonprofitId } + +function issueToken(email) { + const staff = email.toLowerCase().endsWith('@overflow.co'); + const now = Math.floor(Date.now() / 1000); + const payload = { + id: 'user-1', + email, + fullName: 'Mock User', + role: staff ? 'overflowSupport' : 'orgAdmin', + permissions: [{ action: 'manage', subject: 'TAP' }], + nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }], + iat: now, + exp: now + EXPIRE_S, + }; + if (!staff) payload.nonprofitId = NONPROFIT.id; + const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`; + tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id }); + return token; } -let served = 0; -http.createServer((req, res) => { +function bearer(req) { + const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || '')); + if (!m) return null; + const t = tokens.get(m[1]); + if (!t) return null; + if (Date.now() / 1000 >= t.exp) return null; + return t; +} + +function readBody(req) { + return new Promise((resolve) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); } + }); + }); +} + +http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const p = url.pathname; - served += 1; - const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' }; const answer = (code, body) => { - const go = () => sendJson(res, code, body, rl); + const go = () => sendJson(res, code, body); if (SLOW) setTimeout(go, 2000); else go(); }; - const cid = String(req.headers['x-client-id'] || ''); - const key = String(req.headers['x-api-key'] || ''); - console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`); - if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' }); + console.log(`[overflow] ${req.method} ${p}${url.search}`); - if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url)); - const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p); - if (req.method === 'GET' && dOne) { - const d = DESTINATIONS.find((x) => x.id === dOne[1]); - return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' }); + if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') { + const body = await readBody(req); + const email = String((body && body.email) || '').trim(); + const password = String((body && body.password) || ''); + if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' }); + if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' }); + console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`); + return answer(200, { token: issueToken(email) }); } - if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url)); - const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p); - if (gOne) { - const g = GROUPS.find((x) => x.id === gOne[1]); - if (!g) return answer(404, { statusCode: 404, message: 'Group not found' }); - if (req.method === 'GET') return answer(200, { data: groupView(g) }); - if (req.method === 'PATCH' || req.method === 'PUT') { - if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` }); - let body = ''; - req.on('data', (c) => (body += c)); - req.on('end', () => { - let parsed = {}; - try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); } - const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId); - if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' }); - g.destinationId = dest.id; - g.updatedAt = stamp(Date.now()); - console.log(`[overflow] ${g.name} → ${dest.name}`); - answer(200, { data: groupView(g) }); - }); - return; + + const session = bearer(req); + if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' }); + + if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') { + return answer(200, { data: { nonprofits: [NONPROFIT] } }); + } + const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p); + if (req.method === 'POST' && sel) { + if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' }); + session.nonprofitId = NONPROFIT.id; + return answer(204); + } + const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p); + if (kw) { + if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' }); + if (req.method === 'GET') return answer(200, stampedKeywords()); + if (req.method === 'POST') { + const body = await readBody(req); + const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : []; + if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' }); + if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' }); + for (const k of keys) { + const entry = KEYWORDS.find((x) => x.keyword === k); + if (!entry) { + console.log(`[overflow] "${k}" is not a mapped keyword — ignored`); + continue; + } + for (const g of entry.tapGroups) { + const group = GROUPS.find((x) => x.id === g.id); + if (group) group.destinationId = entry.destinationId; + } + console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`); + } + return answer(200, stampedKeywords()); } } - if (req.method === 'GET' && p === '/api/v3/tap/devices') { - const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id }))); - return answer(200, page(devices, url)); - } - if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 }); answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` }); }).listen(OF_PORT, '127.0.0.1', () => { - console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`); + console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`); }); From 2893316a1a8a15f2d19843e686e955d7d3ce092c Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 11 Oct 2026 11:15:45 -0400 Subject: [PATCH 07/23] Vista Cues 1.0.0: which Vista 3 cue lists are live, from the network MIDI that fires them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 1 + README.md | 35 +- modules/vista-cues/client.js | 262 +++++++++++++ modules/vista-cues/mapping.js | 148 ++++++++ modules/vista-cues/module.json | 166 ++++++++ modules/vista-cues/rtpmidi.js | 630 +++++++++++++++++++++++++++++++ modules/vista-cues/server.js | 355 +++++++++++++++++ modules/vista-cues/style.css | 185 +++++++++ modules/vista-cues/vista.test.js | 371 ++++++++++++++++++ tools/vista-midi-mock.js | 177 +++++++++ 10 files changed, 2329 insertions(+), 1 deletion(-) create mode 100644 modules/vista-cues/client.js create mode 100644 modules/vista-cues/mapping.js create mode 100644 modules/vista-cues/module.json create mode 100644 modules/vista-cues/rtpmidi.js create mode 100644 modules/vista-cues/server.js create mode 100644 modules/vista-cues/style.css create mode 100644 modules/vista-cues/vista.test.js create mode 100644 tools/vista-midi-mock.js diff --git a/CLAUDE.md b/CLAUDE.md index 572c2c3..117e8b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ title-bar controls). The rules that matter most: `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). `node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`). + `node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`). ## Reference code (read-only forks — don't develop these here) diff --git a/README.md b/README.md index 1b85fa4..614b85c 100644 --- a/README.md +++ b/README.md @@ -550,6 +550,36 @@ below. Nothing is installed without someone clicking. soon as it does) and **Marks** (how long a mark lives). Tile settings: campuses, card or list layout, and a **Show** group for the day, role, resi source, campuses fed, notes and the updated line. +- **Vista Cues** — which Vista 3 cue lists are live, for a booth where + nobody runs lighting and ProPresenter and the tracks rig fire Vista's cue + lists over network MIDI. Vista sends no feedback of any kind (its OSC only + takes commands), so the module listens instead: it joins the senders' + network MIDI sessions as one more participant, the way Vista does, hears + every note Vista hears, and turns each one into "Go on Walk-in", "Release + Worship 1"… from the cue sheet pasted into admin. The tile shows the list + fired last, large, with how long ago and who fired it; the other lists + still live; the recent notes; and which senders are connected. What it + shows is what Vista was *told*: if Vista is closed or someone works the + desk by hand, the two can differ, and the title bar's eraser forgets every + live list. Plain JavaScript (RTP-MIDI over UDP), no helper; on macOS it + advertises itself over Bonjour so it shows up in Audio MIDI Setup's + Directory. Admin config: up to three senders (a name, and the host and + port of each machine's network session — ProPresenter, the tracks rig, + Companion; ProdDash keeps inviting itself in, so each session's "Who may + connect to me" must be Anyone, or connect from the sender's Directory + instead), the cue sheet (one line per note: `0 = Pre Service`, + `61 release Walk-in`, `127 release all`, `30 flash Blinder Flash 1` for a + momentary hit, also pause, back, note names, a channel, program changes), + middle C naming, whether a Go releases the others, and a folded **This + machine** group for the session name, port (UDP 5040 and 5041) and + Bonjour. A sheet with no release notes uses groups: a line `[Color]` + starts one, a Go in a group replaces that group's live list, and the + first group is the tile's headline — so the tile reads "Message" with + Color · Blue and House · House Lights Message beside it. A Go in that + first group (the main scene) takes over the look and clears the rest, as + Vista's last-takes-precedence does; FX fired after it layer on again + (the **Main group takes over** switch). Tile settings: other lists, + recent, senders, unmapped notes — text size is − / + in the title bar. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. @@ -565,7 +595,10 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js` run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and `node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup board — a plan, its team members, birthdays and photos — run ProdDash with -`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of +`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and +`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue +sheet's notes, as ProPresenter does for Vista — point Vista Cues' first +sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of the [module guide](docs/MODULE-GUIDE.md). ## Install as a desktop web app diff --git a/modules/vista-cues/client.js b/modules/vista-cues/client.js new file mode 100644 index 0000000..83861de --- /dev/null +++ b/modules/vista-cues/client.js @@ -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 = ''; +const PLUS_SVG = ''; +const CLEAR_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 = ` +
+ +
+
+
+
+
+
+
+
+
+
+
`; + 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) => ` +
+ ${l.group ? `${esc(l.group)}` : ''} + ${esc(l.name)} + +
`).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) => ` +
+ ${esc(clockTime(e.at))} + ${esc(e.mapped ? (ACTION_LABEL[e.action] ?? e.action) : 'Unmapped')} + ${esc(e.mapped ? e.list : e.trigger)} + ${esc(e.source)} +
`).join(''); + } + + // Senders + el.sources.hidden = !on('showSources') || !s.sources.length; + if (!el.sources.hidden) { + el.sources.innerHTML = s.sources.map((x) => ` + + ${esc(x.label)} + `).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); + }, + }; +} diff --git a/modules/vista-cues/mapping.js b/modules/vista-cues/mapping.js new file mode 100644 index 0000000..3777e58 --- /dev/null +++ b/modules/vista-cues/mapping.js @@ -0,0 +1,148 @@ +'use strict'; + +/** + * The admin page's "Cue lists" text → rules, and MIDI events → what Vista + * was told to do. + * + * One rule per line, the way it reads on a cue sheet: + * + * 60 = Walk-in note 60 fires Go on "Walk-in" + * C3 = Worship 1 note names work too (see "Middle C" in admin) + * 61 release Walk-in release a list + * 62 pause Walk-in also: back, pause, go (the default) + * 127 release all every list off + * ch2:64 = Message only on MIDI channel 2 (otherwise any channel) + * pc5 = Baptism a program change instead of a note + * 65 = "Back Lights" quote a name that starts with an action word + * # anything after a # is a comment + * + * Groups: a line "[Colors]" starts a group, and every rule below it belongs + * to it until the next "[…]" ("[]" ends grouping). A Go in a group replaces + * whichever list of that group was live — firing "Blue" ends "Red" — which is + * how a sheet without release notes still says what is on. The first group + * in the sheet is the headline the tile shows largest. + * + * 30 flash Blinder Flash 1 a momentary hit: logged, never left live + * + * The separator between the trigger and the rest may be "=", "-", ":", "→", + * a tab or just a space, so a column pasted from a spreadsheet works. + */ + +const NOTE_NAMES = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +const NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +const ACTIONS = [ + // Longest first, so "release all" wins over "release". + [/^(release[\s_-]*all|all[\s_-]*off|releaseall|clear)\b/i, 'releaseAll'], + [/^(release|off|stop|kill)\b/i, 'release'], + [/^(pause|hold)\b/i, 'pause'], + [/^(back|previous|prev)\b/i, 'back'], + [/^(flash|hit|momentary)\b/i, 'flash'], + [/^(go|fire|play|start)\b/i, 'go'], +]; + +/** Octave offset: with middle C (note 60) called "C3", C-2 is note 0; with "C4", C-1 is. */ +function octaveBase(middleC) { + return middleC === 'C4' ? 1 : 2; +} + +/** "F#2" → note number, or null. */ +function noteFromName(token, middleC) { + const m = /^([a-g])(#|♯|b|♭)?(-?\d+)$/i.exec(token); + if (!m) return null; + let pc = NOTE_NAMES[m[1].toLowerCase()]; + if (m[2] === '#' || m[2] === '♯') pc += 1; + if (m[2] === 'b' || m[2] === '♭') pc -= 1; + const n = (Number(m[3]) + octaveBase(middleC)) * 12 + pc; + return n >= 0 && n <= 127 ? n : null; +} + +function noteName(n, middleC) { + return `${NAMES_SHARP[n % 12]}${Math.floor(n / 12) - octaveBase(middleC)}`; +} + +/** + * Parse the whole text. Returns `{ rules, errors, groups }` (groups in sheet order); a line that can't be read + * is reported (line number + why) and skipped, never fatal. + */ +function parseMapping(text, { middleC = 'C3' } = {}) { + const rules = []; + const errors = []; + const groups = []; + let group = ''; + String(text || '').split(/\r?\n/).forEach((raw, i) => { + // A comment is a # that starts the line or follows a space ("C#3" and "Song #2" are not). + const line = raw.replace(/(^|\s)#(\s.*|$)/, '').trim(); + if (!line) return; + const header = /^\[(.*)\]$/.exec(line); + if (header) { + group = header[1].trim(); + if (group && !groups.includes(group)) groups.push(group); + return; + } + const m = /^(?:ch(?:annel)?\s*(\d{1,2})\s*[:/]\s*)?(pc\s*\d{1,3}|note\s*\d{1,3}|\d{1,3}|[a-g](?:#|♯|b|♭)?-?\d)\s*(?:[=:\t→>|,-]+\s*|\s+|$)(.*)$/i.exec(line); + if (!m) { + errors.push({ line: i + 1, text: raw.trim(), why: 'starts with no note' }); + return; + } + const channel = m[1] ? Number(m[1]) : null; + if (channel != null && (channel < 1 || channel > 16)) { + errors.push({ line: i + 1, text: raw.trim(), why: 'channel must be 1–16' }); + return; + } + const trig = m[2].replace(/\s+/g, '').toLowerCase(); + let kind = 'note'; + let number; + if (trig.startsWith('pc')) { + kind = 'program'; + number = Number(trig.slice(2)); + } else if (trig.startsWith('note')) { + number = Number(trig.slice(4)); + } else if (/^\d+$/.test(trig)) { + number = Number(trig); + } else { + number = noteFromName(m[2].replace(/\s+/g, ''), middleC); + } + if (number == null || !Number.isInteger(number) || number < 0 || number > 127) { + errors.push({ line: i + 1, text: raw.trim(), why: 'note must be 0–127' }); + return; + } + let rest = m[3].trim(); + let action = 'go'; + const quoted = (t) => /^"(.*)"$/.exec(t); + for (const [re, a] of quoted(rest) ? [] : ACTIONS) { + const am = re.exec(rest); + if (am) { + action = a; + rest = rest.slice(am[0].length).replace(/^[\s=:→>|,-]+/, '').trim(); + break; + } + } + const q = quoted(rest); + if (q) rest = q[1].trim(); + if (action !== 'releaseAll' && !rest) { + errors.push({ line: i + 1, text: raw.trim(), why: 'no cue list name' }); + return; + } + rules.push({ line: i + 1, channel, kind, number, action, list: action === 'releaseAll' ? '' : rest, group }); + }); + return { rules, errors, groups }; +} + +/** The first rule a MIDI event matches, or null. Only note-ons and program changes fire anything. */ +function match(rules, event) { + let kind; + let number; + if (event.type === 'noteon') { kind = 'note'; number = event.note; } + else if (event.type === 'program') { kind = 'program'; number = event.program; } + else return null; + return rules.find((r) => r.kind === kind && r.number === number && (r.channel == null || r.channel === event.channel)) || null; +} + +/** "Note 60 (C3) · ch 1" — how an event is named on the tile. */ +function triggerLabel(event, middleC) { + if (event.type === 'program') return `PC ${event.program} · ch ${event.channel}`; + return `Note ${event.note} (${noteName(event.note, middleC)}) · ch ${event.channel}`; +} + +module.exports = { parseMapping, match, noteName, noteFromName, triggerLabel }; diff --git a/modules/vista-cues/module.json b/modules/vista-cues/module.json new file mode 100644 index 0000000..a384a34 --- /dev/null +++ b/modules/vista-cues/module.json @@ -0,0 +1,166 @@ +{ + "id": "vista-cues", + "name": "Vista Cues", + "version": "1.0.0", + "proddash": ">=1.5.0", + "description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista", + "permissions": [ + { + "kind": "local-network", + "reason": "Joining the network MIDI sessions that fire Vista's cue lists" + } + ], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 2, + "h": 1 + }, + "defaultSize": { + "w": 5, + "h": 4 + }, + "configSchema": { + "source1Name": { + "type": "string", + "label": "Name", + "default": "ProPresenter", + "group": "First sender", + "help": "What the tile calls notes from this machine." + }, + "source1": { + "type": "endpoint", + "label": "Session", + "default": { + "host": "", + "port": 5004 + }, + "group": "First sender", + "help": "The Mac whose network MIDI session sends Vista its notes, and that session's port (Audio MIDI Setup → Window → Show MIDI Studio → Network; usually 5004)." + }, + "source2Name": { + "type": "string", + "label": "Name", + "default": "Tracks", + "group": "Second sender" + }, + "source2": { + "type": "endpoint", + "label": "Session", + "default": { + "host": "", + "port": 5004 + }, + "group": "Second sender", + "help": "Leave the host empty if this machine joins us from its own side instead." + }, + "source3Name": { + "type": "string", + "label": "Name", + "default": "Companion", + "group": "Third sender" + }, + "source3": { + "type": "endpoint", + "label": "Session", + "default": { + "host": "", + "port": 5004 + }, + "group": "Third sender", + "help": "Companion on this machine counts too: its network session's host and port (this Mac's own name works)." + }, + "cueLists": { + "type": "text", + "label": "Cue lists", + "default": "", + "group": "Cue lists", + "help": "One per line from the cue sheet: 60 = Walk-in · 61 release Walk-in · 127 release all · 30 flash Blinder 1 (momentary). A line like [Colors] starts a group: a Go in a group replaces that group's live list; the first group is the tile's headline. Also pause, back, note names, ch2:64, pc5, and \"quotes\" around a name starting with one of those words." + }, + "middleC": { + "type": "select", + "label": "Middle C", + "default": "C3", + "options": [ + { + "value": "C3", + "label": "C3 (Ableton, Yamaha)" + }, + { + "value": "C4", + "label": "C4 (Logic, scientific)" + } + ], + "group": "Cue lists", + "help": "Only matters for note names in the sheet. Note 60 is middle C either way." + }, + "headTakesOver": { + "type": "switch", + "label": "Main group takes over", + "default": true, + "group": "Cue lists", + "help": "On: a Go in the sheet's first [group] (the main scene) clears every other live list, as a new scene takes over the look. FX fired after it layer on top again." + }, + "exclusive": { + "type": "switch", + "label": "Go releases others", + "default": false, + "group": "Cue lists", + "help": "On: a Go replaces every other live list, groups or not. Leave off when lists stack, and use groups instead." + }, + "sessionName": { + "type": "string", + "label": "Session name", + "default": "ProdDash Lighting", + "group": "This machine", + "help": "How ProdDash appears in other Macs' Audio MIDI Setup Directory." + }, + "sessionPort": { + "type": "number", + "label": "Session port", + "default": 5040, + "min": 1024, + "max": 65534, + "group": "This machine", + "help": "UDP; this one and the next are used. Anything not already taken by a MIDI session on this machine." + }, + "advertise": { + "type": "switch", + "label": "Bonjour", + "default": true, + "group": "This machine", + "help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)." + } + }, + "configGroups": { + "First sender": { + "help": "ProdDash joins the senders' network MIDI sessions, like Vista does, so it hears every note Vista hears. On each sender, in Audio MIDI Setup → MIDI Network Setup, the session must be enabled and 'Who may connect to me' set to Anyone. Or skip the address and connect to \"ProdDash Lighting\" from that Mac's Directory instead." + }, + "This machine": { + "collapsed": true + } + }, + "instanceSchema": { + "showActive": { + "type": "switch", + "label": "Other lists", + "default": true + }, + "showRecent": { + "type": "switch", + "label": "Recent", + "default": true + }, + "showSources": { + "type": "switch", + "label": "Senders", + "default": true + }, + "showUnmapped": { + "type": "switch", + "label": "Unmapped notes", + "default": false + } + } +} diff --git a/modules/vista-cues/rtpmidi.js b/modules/vista-cues/rtpmidi.js new file mode 100644 index 0000000..f44e5cc --- /dev/null +++ b/modules/vista-cues/rtpmidi.js @@ -0,0 +1,630 @@ +'use strict'; + +/** + * A listen-only RTP-MIDI (AppleMIDI) session participant, in plain Node. + * + * This is the protocol behind a macOS "Network" MIDI session (Audio MIDI + * Setup → MIDI Network Setup) and rtpMIDI on Windows: two UDP ports, a + * control port P and a data port P+1. A session sends every MIDI message to + * every participant it is connected to, so joining the same session Vista is + * in hears exactly the notes Vista hears. + * + * Both ways of joining work: + * - someone invites us (we show up in Audio MIDI Setup's Directory, or they + * add our address by hand) and we accept anyone; + * - we invite them (`invite(host, port)`), and keep re-inviting until they + * accept, then keep the connection alive with clock syncs. + * + * We never send MIDI. Packet layouts follow Apple's AppleMIDI session + * protocol and RFC 6295 (RTP payload for MIDI). + */ + +const dgram = require('dgram'); +const dns = require('dns'); +const crypto = require('crypto'); +const { EventEmitter } = require('events'); + +const SIG = 0xffff; +const CMD = { IN: 0x494e, OK: 0x4f4b, NO: 0x4e4f, BY: 0x4259, CK: 0x434b, RS: 0x5253 }; +const PROTOCOL_VERSION = 2; + +const INVITE_RETRY_MS = 1500; // between invitation attempts while setting up +const INVITE_ATTEMPTS = 12; // then give up for a while… +const REINVITE_MS = 5000; // …and start over after this long +const SYNC_FAST_MS = 1500; // first few clock syncs, close together +const SYNC_MS = 10000; // then this often, as macOS does +const SYNC_TIMEOUT_MS = 25000; // no sync answer for this long → connection is gone +const IDLE_MS = 90000; // an inbound participant we have not heard from at all +const FEEDBACK_MS = 1000; // receiver feedback (lets the sender trim its journal) + +/** The session clock: 100 µs ticks since this process started the session. */ +function makeClock() { + const t0 = process.hrtime.bigint(); + return () => (process.hrtime.bigint() - t0) / 100000n; +} + +function randomU32() { + return crypto.randomBytes(4).readUInt32BE(0); +} + +/* ── packets ─────────────────────────────────────────────────────────── */ + +function sessionPacket(cmd, token, ssrc, name) { + const nameBuf = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`, 'utf8'); + const b = Buffer.alloc(16 + nameBuf.length); + b.writeUInt16BE(SIG, 0); + b.writeUInt16BE(cmd, 2); + b.writeUInt32BE(PROTOCOL_VERSION, 4); + b.writeUInt32BE(token >>> 0, 8); + b.writeUInt32BE(ssrc >>> 0, 12); + nameBuf.copy(b, 16); + return b; +} + +function syncPacket(ssrc, count, ts) { + const b = Buffer.alloc(36); + b.writeUInt16BE(SIG, 0); + b.writeUInt16BE(CMD.CK, 2); + b.writeUInt32BE(ssrc >>> 0, 4); + b.writeUInt8(count, 8); + for (let i = 0; i < 3; i++) b.writeBigUInt64BE(BigInt.asUintN(64, ts[i] || 0n), 12 + i * 8); + return b; +} + +function feedbackPacket(ssrc, seq) { + const b = Buffer.alloc(12); + b.writeUInt16BE(SIG, 0); + b.writeUInt16BE(CMD.RS, 2); + b.writeUInt32BE(ssrc >>> 0, 4); + b.writeUInt16BE(seq & 0xffff, 8); + return b; +} + +/** A session command (IN/OK/NO/BY/CK/RS), or null when it isn't one. */ +function parseSession(buf) { + if (buf.length < 4 || buf.readUInt16BE(0) !== SIG) return null; + const cmd = buf.readUInt16BE(2); + if (cmd === CMD.CK) { + if (buf.length < 36) return null; + return { + cmd, + ssrc: buf.readUInt32BE(4), + count: buf.readUInt8(8), + ts: [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)], + }; + } + if (cmd === CMD.RS) { + if (buf.length < 12) return null; + return { cmd, ssrc: buf.readUInt32BE(4), seq: buf.readUInt16BE(8) }; + } + if (cmd === CMD.IN || cmd === CMD.OK || cmd === CMD.NO || cmd === CMD.BY) { + if (buf.length < 16) return null; + let name = ''; + if (buf.length > 16) { + const end = buf.indexOf(0, 16); + name = buf.toString('utf8', 16, end < 0 ? buf.length : end); + } + return { cmd, version: buf.readUInt32BE(4), token: buf.readUInt32BE(8), ssrc: buf.readUInt32BE(12), name }; + } + return null; +} + +/** Data bytes that follow a status byte (sysex is handled separately). */ +function dataLength(status) { + const hi = status & 0xf0; + if (hi === 0xc0 || hi === 0xd0) return 1; + if (hi < 0xf0) return 2; + if (status === 0xf1 || status === 0xf3) return 1; + if (status === 0xf2) return 2; + return 0; +} + +/** + * The MIDI commands in one RTP-MIDI packet (RFC 6295 §3), as + * `{ status, data: [..] }` in order. Journal data is ignored: we only show + * what is happening, and a note lost on the wire is one Vista also missed. + */ +function parseRtpMidi(buf) { + if (buf.length < 13 || (buf[0] >> 6) !== 2) return null; + const cc = buf[0] & 0x0f; + const ext = Boolean(buf[0] & 0x10); + const seq = buf.readUInt16BE(2); + const ssrc = buf.readUInt32BE(8); + let off = 12 + cc * 4; + if (ext) { + if (buf.length < off + 4) return null; + off += 4 + buf.readUInt16BE(off + 2) * 4; + } + if (off >= buf.length) return null; + const header = buf[off]; + const big = Boolean(header & 0x80); + const zero = Boolean(header & 0x20); + let len = header & 0x0f; + if (big) { + if (off + 1 >= buf.length) return null; + len = (len << 8) | buf[off + 1]; + off += 2; + } else { + off += 1; + } + const end = Math.min(buf.length, off + len); + const messages = []; + let running = 0; + let first = true; + while (off < end) { + if (!first || zero) { + // Delta time: 1–4 octets, high bit means "more follow". + let n = 0; + while (off < end && (buf[off] & 0x80) && n < 3) { off++; n++; } + off++; + if (off >= end) break; + } + first = false; + let status = buf[off]; + if (status & 0x80) { + off++; + } else if (running) { + status = running; + } else { + break; // data byte with nothing to run on: give up on this packet + } + if (status === 0xf0 || status === 0xf7) { + // A sysex, or a segment of one: runs to F7 (end), F0 (more follows) or F4 (cancelled). + const start = off; + while (off < end && buf[off] !== 0xf7 && buf[off] !== 0xf0 && buf[off] !== 0xf4) off++; + const data = Array.from(buf.subarray(start, off)); + off++; + messages.push({ status, data }); + running = 0; + continue; + } + if (status < 0xf0) running = status; + else if (status < 0xf8) running = 0; // system common cancels running status; realtime doesn't + const n = dataLength(status); + if (off + n > end) break; + messages.push({ status, data: Array.from(buf.subarray(off, off + n)) }); + off += n; + } + return { seq, ssrc, messages }; +} + +/** `{ status, data }` → a plain event, or null for what nobody here cares about. */ +function describe(msg) { + const hi = msg.status & 0xf0; + const channel = (msg.status & 0x0f) + 1; + if (hi === 0x90 && msg.data[1] > 0) return { type: 'noteon', channel, note: msg.data[0], velocity: msg.data[1] }; + if (hi === 0x80 || (hi === 0x90 && msg.data[1] === 0)) return { type: 'noteoff', channel, note: msg.data[0] }; + if (hi === 0xc0) return { type: 'program', channel, program: msg.data[0] }; + if (hi === 0xb0) return { type: 'cc', channel, controller: msg.data[0], value: msg.data[1] }; + return null; +} + +/* ── the session ─────────────────────────────────────────────────────── */ + +class Session extends EventEmitter { + /** + * @param {{ name: string, port: number, log?: Function }} opts + * Events: 'midi' ({ participant, event }), 'change' (participants or + * listening changed), 'listening' (both ports bound). + */ + constructor({ name, port, log }) { + super(); + this.name = name || 'ProdDash'; + this.port = port; + this.log = log || (() => {}); + this.ssrc = randomU32(); + this.now = makeClock(); + this.control = null; + this.data = null; + this.participants = new Map(); // key → participant + this.invites = new Map(); // key "host:port" → outbound invitation + this.strangers = new Map(); // address → when we last told it we're not in its session + this.timers = new Set(); + this.stopped = false; + this.error = ''; + } + + /** + * Open both ports. Resolves once both are bound — or once the first try + * failed, with `error` set. A port that is busy (our own previous session + * still closing after a config save, or another program) is retried every + * few seconds for as long as the session runs; 'listening' fires when it + * finally binds. + */ + start() { + this.every(FEEDBACK_MS, () => this.sendFeedback()); + this.every(5000, () => this.reap()); + return new Promise((resolve) => { + let first = true; + const attempt = (delay) => this.later(delay, async () => { + const ok = await this.bind(); + if (ok) { + this.error = ''; + this.emit('listening'); + this.emit('change'); + } else { + this.emit('change'); + attempt(first ? 250 : 3000); + } + if (first) { first = false; resolve(ok); } + }); + attempt(0); + }); + } + + /** Bind control and data sockets together; on failure close both. */ + bind() { + const open = (port, which) => new Promise((resolve) => { + const sock = dgram.createSocket('udp4'); + const fail = (e) => { + try { sock.close(); } catch { /* not open */ } + resolve({ sock: null, port, e }); + }; + sock.once('error', fail); + sock.bind(port, () => { + sock.removeListener('error', fail); + sock.on('error', (e) => this.log(`socket error on ${port}: ${e.message}`)); + sock.on('message', (buf, rinfo) => { + try { this.onPacket(buf, rinfo, which); } catch (e) { this.log(`bad packet from ${rinfo.address}: ${e.message}`); } + }); + resolve({ sock, port }); + }); + }); + return Promise.all([open(this.port, 'control'), open(this.port + 1, 'data')]).then(([c, d]) => { + if (this.stopped || !c.sock || !d.sock) { + for (const x of [c, d]) if (x.sock) { try { x.sock.close(); } catch { /* closed */ } } + const bad = !c.sock ? c : d; + if (bad.e) { + const msg = bad.e.code === 'EADDRINUSE' + ? `UDP port ${bad.port} is in use on this machine — pick another session port in /admin` + : `UDP port ${bad.port}: ${bad.e.message}`; + if (msg !== this.error) this.log(msg); + this.error = msg; + } + return false; + } + this.control = c.sock; + this.data = d.sock; + return true; + }); + } + + stop() { + this.stopped = true; + for (const t of this.timers) { clearTimeout(t); clearInterval(t); } + this.timers.clear(); + const control = this.control; + const data = this.data; + const close = () => { + for (const s of [control, data]) { try { s && s.close(); } catch { /* closed */ } } + }; + // Say goodbye, then close once the goodbyes have left (a config save + // re-inits straight away and wants these ports back). + const byes = [...this.participants.values()].filter((p) => p.connected); + let left = byes.length; + if (!control || !left) close(); + else { + for (const p of byes) { + try { + control.send(sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address, () => { if (--left === 0) close(); }); + } catch { if (--left === 0) close(); } + } + } + this.control = null; + this.data = null; + this.participants.clear(); + this.invites.clear(); + } + + every(ms, fn) { + const t = setInterval(fn, ms); + t.unref?.(); + this.timers.add(t); + return t; + } + + later(ms, fn) { + const t = setTimeout(() => { this.timers.delete(t); if (!this.stopped) fn(); }, ms); + t.unref?.(); + this.timers.add(t); + return t; + } + + send(sock, buf, port, address) { + if (!sock || this.stopped) return; + try { sock.send(buf, port, address); } catch { /* socket closed */ } + } + + /** Every participant, for display. */ + list() { + return [...this.participants.values()].map((p) => ({ + name: p.name, + address: p.address, + port: p.controlPort, + connected: p.connected, + outbound: p.outbound, + lastHeard: p.lastHeard || null, + since: p.since || null, + })); + } + + /* — outbound invitations — */ + + /** + * Keep a connection to the session at host:port. Re-invites on its own + * whenever the connection drops. Returns the invitation's state object. + */ + invite(host, port, label) { + const key = `${host}:${port}`; + if (this.invites.has(key)) return this.invites.get(key); + const inv = { key, host, port, label: label || host, address: '', token: 0, attempts: 0, stage: 'idle', error: '', timer: null }; + this.invites.set(key, inv); + this.beginInvite(inv); + return inv; + } + + beginInvite(inv) { + if (this.stopped || !this.invites.has(inv.key)) return; + dns.lookup(inv.host, { family: 4 }, (err, address) => { + if (this.stopped) return; + if (err) { + inv.error = `can't find ${inv.host}`; + inv.stage = 'idle'; + this.emit('change'); + this.later(REINVITE_MS, () => this.beginInvite(inv)); + return; + } + inv.address = address; + // Already in a session with that machine (it invited us)? Nothing to do. + const existing = this.findByAddress(address, inv.port); + if (existing && existing.connected) { + existing.label = inv.label; + inv.stage = 'connected'; + inv.error = ''; + this.emit('change'); + return; + } + inv.token = randomU32(); + inv.attempts = 0; + inv.stage = 'control'; + this.sendInvite(inv); + }); + } + + sendInvite(inv) { + if (this.stopped || !this.invites.has(inv.key)) return; + if (inv.stage !== 'control' && inv.stage !== 'data') return; + if (inv.attempts >= INVITE_ATTEMPTS) { + inv.error = inv.error || 'no answer'; + inv.stage = 'idle'; + this.emit('change'); + this.later(REINVITE_MS, () => this.beginInvite(inv)); + return; + } + inv.attempts++; + const sock = inv.stage === 'control' ? this.control : this.data; + const port = inv.stage === 'control' ? inv.port : inv.port + 1; + this.send(sock, sessionPacket(CMD.IN, inv.token, this.ssrc, this.name), port, inv.address); + clearTimeout(inv.timer); + inv.timer = this.later(INVITE_RETRY_MS, () => this.sendInvite(inv)); + } + + inviteFor(rinfo, token) { + for (const inv of this.invites.values()) { + if (inv.token === token && inv.address === rinfo.address) return inv; + } + return null; + } + + findByAddress(address, controlPort) { + for (const p of this.participants.values()) { + if (p.address === address && (controlPort == null || p.controlPort === controlPort)) return p; + } + return null; + } + + /* — packets in — */ + + onPacket(buf, rinfo, which) { + const s = parseSession(buf); + if (!s) { + if (which === 'data') this.onRtp(buf, rinfo); + return; + } + const sock = which === 'control' ? this.control : this.data; + switch (s.cmd) { + case CMD.IN: return this.onInvitation(s, rinfo, which, sock); + case CMD.OK: return this.onAccepted(s, rinfo, which); + case CMD.NO: return this.onRejected(s, rinfo); + case CMD.BY: return this.onBye(s, rinfo); + case CMD.CK: return this.onSync(s, rinfo); + default: return undefined; // RS from a peer: nothing for a listener to do + } + } + + onInvitation(s, rinfo, which, sock) { + // We accept anyone: being in more sessions only means hearing more cues. + this.send(sock, sessionPacket(CMD.OK, s.token, this.ssrc, this.name), rinfo.port, rinfo.address); + const key = `in:${s.ssrc}`; + let p = this.participants.get(key); + if (which === 'control') { + if (!p) { + p = { + key, ssrc: s.ssrc, token: s.token, name: s.name || rinfo.address, label: '', + address: rinfo.address, controlPort: rinfo.port, dataPort: rinfo.port + 1, + outbound: false, connected: false, lastSeq: -1, fedBack: -1, lastHeard: Date.now(), since: 0, + }; + this.participants.set(key, p); + this.log(`invited by "${p.name}" at ${rinfo.address}:${rinfo.port}`); + } else { + p.token = s.token; + p.controlPort = rinfo.port; + p.name = s.name || p.name; + } + } else if (p) { + p.dataPort = rinfo.port; + p.connected = true; + p.since = Date.now(); + p.lastHeard = Date.now(); + this.log(`in session with "${p.name}" (${p.address})`); + for (const inv of this.invites.values()) { + if (inv.address === p.address && inv.port === p.controlPort) { + p.label = inv.label; + inv.stage = 'connected'; + inv.error = ''; + clearTimeout(inv.timer); + } + } + this.emit('change'); + } + } + + onAccepted(s, rinfo, which) { + const inv = this.inviteFor(rinfo, s.token); + if (!inv) return; + clearTimeout(inv.timer); + if (which === 'control' && inv.stage === 'control') { + inv.stage = 'data'; + inv.attempts = 0; + inv.remoteName = s.name; + this.sendInvite(inv); + return; + } + if (which === 'data' && inv.stage === 'data') { + const key = `out:${inv.key}`; + // Drop any stale record of the same remote before taking the new one. + for (const [k, q] of this.participants) if (q.ssrc === s.ssrc && k !== key) this.participants.delete(k); + const p = { + key, ssrc: s.ssrc, token: inv.token, name: inv.remoteName || s.name || inv.host, label: inv.label, + address: rinfo.address, controlPort: inv.port, dataPort: rinfo.port, + outbound: true, connected: true, lastSeq: -1, fedBack: -1, + lastHeard: Date.now(), lastSyncAnswer: Date.now(), since: Date.now(), syncs: 0, + }; + this.participants.set(key, p); + inv.stage = 'connected'; + inv.error = ''; + this.log(`joined "${p.name}" at ${inv.host} (${rinfo.address}:${inv.port})`); + this.emit('change'); + this.startSync(p, inv); + } + } + + onRejected(s, rinfo) { + const inv = this.inviteFor(rinfo, s.token); + if (!inv) return; + clearTimeout(inv.timer); + inv.stage = 'idle'; + inv.error = 'the session refused us — set "Who may connect" to Anyone in Audio MIDI Setup'; + this.log(`${inv.host}: invitation refused`); + this.emit('change'); + this.later(REINVITE_MS * 2, () => this.beginInvite(inv)); + } + + onBye(s, rinfo) { + for (const [k, p] of this.participants) { + if (p.ssrc === s.ssrc && p.address === rinfo.address) this.drop(k, 'left the session'); + } + } + + drop(key, why) { + const p = this.participants.get(key); + if (!p) return; + this.participants.delete(key); + clearInterval(p.syncTimer); + this.timers.delete(p.syncTimer); + this.log(`"${p.name}" ${why}`); + // If we were asked to keep this one, go and get it back. + for (const inv of this.invites.values()) { + if (inv.address === p.address && inv.port === p.controlPort && inv.stage === 'connected') { + inv.stage = 'idle'; + inv.error = why; + this.later(p.outbound ? 1000 : REINVITE_MS, () => this.beginInvite(inv)); + } + } + this.emit('change'); + } + + /* — clock sync — */ + + startSync(p) { + const tick = () => { + if (!this.participants.has(p.key)) return; + if (Date.now() - p.lastSyncAnswer > SYNC_TIMEOUT_MS) { + this.send(this.control, sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address); + this.drop(p.key, 'stopped answering'); + return; + } + this.send(this.data, syncPacket(this.ssrc, 0, [this.now(), 0n, 0n]), p.dataPort, p.address); + p.syncs++; + if (p.syncs === 6) { + clearInterval(p.syncTimer); + this.timers.delete(p.syncTimer); + p.syncTimer = this.every(SYNC_MS, tick); + } + }; + p.syncTimer = this.every(SYNC_FAST_MS, tick); + tick(); + } + + onSync(s, rinfo) { + const p = [...this.participants.values()].find((q) => q.ssrc === s.ssrc); + if (p) p.lastHeard = Date.now(); + if (s.count === 0) { + this.send(this.data, syncPacket(this.ssrc, 1, [s.ts[0], this.now(), 0n]), rinfo.port, rinfo.address); + } else if (s.count === 1) { + if (p) p.lastSyncAnswer = Date.now(); + this.send(this.data, syncPacket(this.ssrc, 2, [s.ts[0], s.ts[1], this.now()]), rinfo.port, rinfo.address); + } + // count 2 completes a sync someone else started; nothing to answer. + } + + /** Drop inbound participants that have gone quiet (their machine slept or left without a BY). */ + reap() { + const now = Date.now(); + for (const [k, p] of this.participants) { + if (!p.outbound && now - (p.lastHeard || 0) > IDLE_MS) this.drop(k, 'went quiet'); + } + } + + sendFeedback() { + for (const p of this.participants.values()) { + if (!p.connected || p.lastSeq < 0 || p.lastSeq === p.fedBack) continue; + p.fedBack = p.lastSeq; + this.send(this.control, feedbackPacket(this.ssrc, p.lastSeq), p.controlPort, p.address); + } + } + + /* — MIDI — */ + + onRtp(buf, rinfo) { + const pkt = parseRtpMidi(buf); + if (!pkt) return; + let p = [...this.participants.values()].find((q) => q.ssrc === pkt.ssrc && q.connected); + if (!p) { + // MIDI from someone not in a session with us (a stale session after our restart). + // Ask them to start over so the session comes back properly — once in a while, not per note. + const last = this.strangers.get(rinfo.address) || 0; + if (Date.now() - last > 5000) { + this.strangers.set(rinfo.address, Date.now()); + this.send(this.control, sessionPacket(CMD.BY, 0, this.ssrc), rinfo.port - 1, rinfo.address); + } + return; + } + p.lastHeard = Date.now(); + p.lastSeq = pkt.seq; + for (const m of pkt.messages) { + const event = describe(m); + if (event) this.emit('midi', { participant: p, event }); + } + } +} + +module.exports = { + Session, + parseRtpMidi, + parseSession, + describe, + sessionPacket, + syncPacket, + CMD, +}; diff --git a/modules/vista-cues/server.js b/modules/vista-cues/server.js new file mode 100644 index 0000000..4e29938 --- /dev/null +++ b/modules/vista-cues/server.js @@ -0,0 +1,355 @@ +'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}` }; + }, + }; + }, + + 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 }); + }, + }; + }, +}; diff --git a/modules/vista-cues/style.css b/modules/vista-cues/style.css new file mode 100644 index 0000000..b6eae02 --- /dev/null +++ b/modules/vista-cues/style.css @@ -0,0 +1,185 @@ +/* Vista Cues. Sizes hang off the tile (container units) times the tile's + own text scale (--vc-scale, the title bar's − / +), so one tile reads the + same at 3×2 on a laptop as at 8×5 on a wall. Colours are the shell's. */ + +[data-module="vista-cues"] .vc-root { + --tone: var(--text); + --vc-scale: 1; + position: absolute; + inset: 0; + container-type: size; + overflow: hidden; + color: var(--text); + font-variant-numeric: tabular-nums; + user-select: none; + -webkit-user-select: none; +} +[data-module="vista-cues"] .vc-root[data-tone="ok"] { --tone: var(--accent); } +[data-module="vista-cues"] .vc-root[data-tone="warn"] { --tone: var(--warn); } +[data-module="vista-cues"] .vc-root[data-tone="muted"] { --tone: var(--muted); } +[data-module="vista-cues"] [hidden] { display: none !important; } + +[data-module="vista-cues"] .vc-body { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: calc(clamp(3px, 2.5cqh, 14px) * var(--vc-scale)); + padding: clamp(6px, 3.5cqmin, 22px); + min-width: 0; +} + +[data-module="vista-cues"] .vc-offline { + position: absolute; + top: clamp(4px, 3cqmin, 14px); + right: clamp(6px, 3.5cqmin, 22px); + z-index: 2; + font-size: calc(clamp(9px, 4.5cqmin, 14px) * var(--vc-scale)); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--danger); +} +[data-module="vista-cues"] .vc-root.is-offline .vc-now { opacity: 0.45; filter: grayscale(0.6); } + +/* ── now ── */ + +[data-module="vista-cues"] .vc-now { + flex: 0 0 auto; + min-width: 0; + padding-left: clamp(6px, 2cqmin, 14px); + border-left: clamp(3px, 1.1cqmin, 7px) solid var(--tone); +} +[data-module="vista-cues"] .vc-kicker { + display: flex; + align-items: center; + gap: 0.6em; + font-size: calc(clamp(10px, 5cqmin, 18px) * var(--vc-scale)); + color: var(--muted); +} +[data-module="vista-cues"] .vc-pill { + padding: 0.05em 0.55em; + border-radius: 999px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + background: var(--tone); + color: var(--accent-text); +} +[data-module="vista-cues"] .vc-root[data-tone="warn"] .vc-pill { color: var(--bg); } +[data-module="vista-cues"] .vc-ago { font-weight: 600; } +[data-module="vista-cues"] .vc-name-wrap { min-width: 0; } +[data-module="vista-cues"] .vc-name { + font-size: calc(clamp(16px, 14cqmin, 96px) * var(--vc-scale)); + line-height: 1.12; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} +[data-module="vista-cues"] .vc-root[data-tone="muted"] .vc-name { color: var(--muted); font-weight: 600; } +[data-module="vista-cues"] .vc-meta { + font-size: calc(clamp(10px, 4.6cqmin, 18px) * var(--vc-scale)); + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="vista-cues"] .vc-meta:empty { display: none; } + +/* ── other live lists ── */ + +[data-module="vista-cues"] .vc-active { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + flex: 0 0 auto; + font-size: calc(clamp(10px, 4.4cqmin, 17px) * var(--vc-scale)); +} +[data-module="vista-cues"] .vc-chip { + display: inline-flex; + align-items: baseline; + gap: 0.5em; + max-width: 100%; + padding: 0.2em 0.65em; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} +[data-module="vista-cues"] .vc-chip.is-paused { + border-color: color-mix(in srgb, var(--warn) 55%, transparent); + background: color-mix(in srgb, var(--warn) 14%, transparent); +} +[data-module="vista-cues"] .vc-chip-name { + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="vista-cues"] .vc-chip-ago, +[data-module="vista-cues"] .vc-chip-group { color: var(--muted); } +[data-module="vista-cues"] .vc-chip-group { font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em; } + +/* ── recent ── */ + +[data-module="vista-cues"] .vc-recent { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + font-size: calc(clamp(10px, 3.8cqmin, 15px) * var(--vc-scale)); + border-top: 1px solid var(--border); + padding-top: 0.35em; +} +[data-module="vista-cues"] .vc-row { + display: grid; + grid-template-columns: auto 6.2em minmax(0, 1fr) auto; + gap: 0.8em; + align-items: baseline; + padding: 0.12em 0; + white-space: nowrap; +} +[data-module="vista-cues"] .vc-row > span { overflow: hidden; text-overflow: ellipsis; } +[data-module="vista-cues"] .vc-row-time, +[data-module="vista-cues"] .vc-row-source { color: var(--muted); } +[data-module="vista-cues"] .vc-row-action { font-weight: 700; color: var(--accent); } +[data-module="vista-cues"] .vc-row[data-action="release"] .vc-row-action, +[data-module="vista-cues"] .vc-row[data-action="releaseAll"] .vc-row-action, +[data-module="vista-cues"] .vc-row[data-action="cleared"] .vc-row-action { color: var(--muted); } +[data-module="vista-cues"] .vc-row[data-action="pause"] .vc-row-action { color: var(--warn); } +[data-module="vista-cues"] .vc-row[data-action="flash"] .vc-row-action { color: var(--text); } +[data-module="vista-cues"] .vc-row.is-unmapped { opacity: 0.6; } +[data-module="vista-cues"] .vc-row.is-unmapped .vc-row-action { color: var(--muted); font-weight: 500; } + +@container (max-width: 300px) { + [data-module="vista-cues"] .vc-row { grid-template-columns: 5.2em minmax(0, 1fr); } + [data-module="vista-cues"] .vc-row-time, + [data-module="vista-cues"] .vc-row-source { display: none; } +} +@container (max-height: 140px) { + [data-module="vista-cues"] .vc-recent, + [data-module="vista-cues"] .vc-active { display: none; } +} + +/* ── senders ── */ + +[data-module="vista-cues"] .vc-sources { + display: flex; + flex-wrap: wrap; + gap: 0.9em; + margin-top: auto; + font-size: calc(clamp(9px, 3.4cqmin, 13px) * var(--vc-scale)); + color: var(--muted); +} +[data-module="vista-cues"] .vc-src { display: inline-flex; align-items: center; gap: 0.4em; } +[data-module="vista-cues"] .vc-dot { + width: 0.65em; + height: 0.65em; + border-radius: 50%; + background: var(--danger); +} +[data-module="vista-cues"] .vc-src.is-on { color: var(--text); } +[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); } +@container (max-height: 140px) { + [data-module="vista-cues"] .vc-sources { display: none; } +} diff --git a/modules/vista-cues/vista.test.js b/modules/vista-cues/vista.test.js new file mode 100644 index 0000000..3b3f300 --- /dev/null +++ b/modules/vista-cues/vista.test.js @@ -0,0 +1,371 @@ +'use strict'; + +/** + * Tests for Vista Cues. + * + * node modules/vista-cues/vista.test.js + * + * Plain Node, no dependencies; exits non-zero on the first failure. Covers + * the cue-sheet parser, RTP-MIDI packet decoding, and a real session over + * loopback UDP: a fake sender invites the module's session, plays notes, and + * the server entry's state follows. + */ + +const assert = require('assert'); +const dgram = require('dgram'); +const http = require('http'); +const { parseMapping, match, noteFromName } = require('./mapping'); +const { parseRtpMidi, describe, Session } = require('./rtpmidi'); + +let passed = 0; +async function test(name, fn) { + await fn(); + passed++; + console.log(` ok ${name}`); +} + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +async function until(fn, ms = 3000) { + const end = Date.now() + ms; + while (Date.now() < end) { + if (fn()) return; + await wait(20); + } + throw new Error('timed out waiting'); +} + +/* ── a minimal independent sender ─────────────────────────────────────── */ + +function sessionPkt(cmd, token, ssrc, name) { + const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`); + const b = Buffer.alloc(16 + n.length); + b.writeUInt16BE(0xffff, 0); + b.write(cmd, 2, 'ascii'); + b.writeUInt32BE(2, 4); + b.writeUInt32BE(token, 8); + b.writeUInt32BE(ssrc, 12); + n.copy(b, 16); + return b; +} + +function rtp(ssrc, seq, midiBytes, flags = 0) { + const midi = Buffer.from(midiBytes); + const b = Buffer.alloc(13 + midi.length); + b[0] = 0x80; + b[1] = 0x61; + b.writeUInt16BE(seq, 2); + b.writeUInt32BE(1234, 4); + b.writeUInt32BE(ssrc, 8); + b[12] = flags | midi.length; + midi.copy(b, 13); + return b; +} + +/** A sender that accepts invitations (like a Mac session) and can play notes. */ +async function fakeSender(port, name) { + const ssrc = 0x0badf00d; + const control = dgram.createSocket('udp4'); + const data = dgram.createSocket('udp4'); + const peers = []; + const syncs = []; + let seq = 100; + const handle = (sock, which) => (buf, r) => { + const cmd = buf.toString('ascii', 2, 4); + if (cmd === 'IN') { + sock.send(sessionPkt('OK', buf.readUInt32BE(8), ssrc, name), r.port, r.address); + if (which === 'data') peers.push({ address: r.address, port: r.port }); + } else if (cmd === 'CK') { + syncs.push(buf[8]); + if (buf[8] === 0) { + const out = Buffer.from(buf); + out.writeUInt32BE(ssrc, 4); + out[8] = 1; + data.send(out, r.port, r.address); + } + } + }; + control.on('message', handle(control, 'control')); + data.on('message', handle(data, 'data')); + await new Promise((r) => control.bind(port, '127.0.0.1', r)); + await new Promise((r) => data.bind(port + 1, '127.0.0.1', r)); + return { + peers, + syncs, + note(n, ch = 1) { + for (const p of peers) data.send(rtp(ssrc, seq++, [0x90 | (ch - 1), n, 100, 0x00, n, 0]), p.port, p.address); + }, + close() { control.close(); data.close(); }, + }; +} + +function freePort() { + return new Promise((resolve) => { + const s = dgram.createSocket('udp4'); + s.bind(0, () => { const p = s.address().port; s.close(() => resolve(p)); }); + }); +} + +/** Two adjacent free UDP ports, as a session needs. */ +async function freePair() { + for (let i = 0; i < 20; i++) { + const p = await freePort(); + if (p > 65000) continue; + const ok = await new Promise((resolve) => { + const s = dgram.createSocket('udp4'); + s.on('error', () => resolve(false)); + s.bind(p + 1, () => s.close(() => resolve(true))); + }); + if (ok) return p; + } + throw new Error('no free port pair'); +} + +(async () => { + console.log('mapping'); + + await test('reads the cue sheet forms', () => { + const { rules, errors } = parseMapping([ + '# comment', + '60 = Walk-in', + 'C3 - Worship 1', + '61 release Walk-in', + '62\tPause\tWorship 1', + '127 release all', + 'ch2:64 = Message # only on channel 2', + 'pc5 = Baptism', + 'note 66: Go: Song #2', + '67 = "Back Lights"', + 'F#2 = Hazer', + ].join('\n')); + assert.deepStrictEqual(errors, []); + const brief = rules.map((r) => [r.channel, r.kind, r.number, r.action, r.list]); + assert.deepStrictEqual(brief, [ + [null, 'note', 60, 'go', 'Walk-in'], + [null, 'note', 60, 'go', 'Worship 1'], + [null, 'note', 61, 'release', 'Walk-in'], + [null, 'note', 62, 'pause', 'Worship 1'], + [null, 'note', 127, 'releaseAll', ''], + [2, 'note', 64, 'go', 'Message'], + [null, 'program', 5, 'go', 'Baptism'], + [null, 'note', 66, 'go', 'Song #2'], + [null, 'note', 67, 'go', 'Back Lights'], + [null, 'note', 54, 'go', 'Hazer'], + ]); + }); + + await test('middle C naming', () => { + assert.strictEqual(noteFromName('C3', 'C3'), 60); + assert.strictEqual(noteFromName('C4', 'C4'), 60); + assert.strictEqual(noteFromName('C-2', 'C3'), 0); + assert.strictEqual(noteFromName('Bb3', 'C3'), 70); + }); + + await test('bad lines are reported, not fatal', () => { + const { rules, errors } = parseMapping('hello\n200 = Big\n61 release\nch17:3 = X\n60 = OK'); + assert.strictEqual(rules.length, 1); + assert.deepStrictEqual(errors.map((e) => e.line), [1, 2, 3, 4]); + }); + + await test('groups and flashes', () => { + const { rules, groups, errors } = parseMapping('5 = Loose\n[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n23 = Blue\n30 flash Blinder Flash 1\n[]\n9 = After'); + assert.deepStrictEqual(errors, []); + assert.deepStrictEqual(groups, ['Scene', 'Color']); + assert.deepStrictEqual(rules.map((r) => [r.number, r.group, r.action]), [ + [5, '', 'go'], [0, 'Scene', 'go'], [8, 'Scene', 'go'], [23, 'Color', 'go'], [30, 'Color', 'flash'], [9, '', 'go'], + ]); + }); + + await test('the NA cue sheet parses cleanly', () => { + const fs = require('fs'); + const path = require('path'); + const file = path.join(__dirname, '..', '..', '..', 'NA cue sheet.txt'); + if (!fs.existsSync(file)) return; + const { rules, errors, groups } = parseMapping(fs.readFileSync(file, 'utf8')); + assert.deepStrictEqual(errors, []); + assert.strictEqual(rules.length, 53); // every note marked TRUE for NA in the sheet + assert.deepStrictEqual(groups, ['Main', 'Color', 'Blinders', 'Build', 'House', 'Lekos']); + assert.strictEqual(new Set(rules.map((r) => r.number)).size, rules.length); + }); + + await test('matching respects channel and kind', () => { + const { rules } = parseMapping('ch2:64 = Two\n64 = Any\npc64 = Program'); + assert.strictEqual(match(rules, { type: 'noteon', channel: 2, note: 64 }).list, 'Two'); + assert.strictEqual(match(rules, { type: 'noteon', channel: 1, note: 64 }).list, 'Any'); + assert.strictEqual(match(rules, { type: 'program', channel: 1, program: 64 }).list, 'Program'); + assert.strictEqual(match(rules, { type: 'noteoff', channel: 1, note: 64 }), null); + }); + + console.log('rtp-midi'); + + await test('note on + running-status note off', () => { + const p = parseRtpMidi(rtp(7, 9, [0x90, 60, 100, 0x00, 60, 0])); + assert.strictEqual(p.ssrc, 7); + assert.strictEqual(p.seq, 9); + assert.deepStrictEqual(p.messages.map(describe), [ + { type: 'noteon', channel: 1, note: 60, velocity: 100 }, + { type: 'noteoff', channel: 1, note: 60 }, + ]); + }); + + await test('Z flag, multi-byte deltas, program change, sysex in between', () => { + const p = parseRtpMidi(rtp(7, 1, [0x05, 0xc2, 4, 0x81, 0x00, 0xf0, 0x7f, 0x01, 0xf7, 0x00, 0x93, 61, 9], 0x20)); + assert.deepStrictEqual(p.messages.map(describe).filter(Boolean), [ + { type: 'program', channel: 3, program: 4 }, + { type: 'noteon', channel: 4, note: 61, velocity: 9 }, + ]); + }); + + await test('journal bytes after the command section are ignored', () => { + const b = Buffer.concat([rtp(7, 1, [0x90, 62, 1], 0x40), Buffer.from([0x80, 0x00, 0x05, 0x01, 0x02])]); + assert.deepStrictEqual(parseRtpMidi(b).messages.map(describe), [{ type: 'noteon', channel: 1, note: 62, velocity: 1 }]); + }); + + console.log('session'); + + await test('invites a sender, keeps sync, and hears its notes', async () => { + const senderPort = await freePair(); + const sender = await fakeSender(senderPort, 'Fake PP'); + const port = await freePair(); + const s = new Session({ name: 'Test', port }); + assert.ok(await s.start()); + const heard = []; + s.on('midi', ({ participant, event }) => heard.push([participant.label, event.type, event.note])); + s.invite('127.0.0.1', senderPort, 'ProPresenter'); + await until(() => s.list().some((p) => p.connected)); + await until(() => sender.syncs.includes(2)); // we answered its count-1 with a count-2 + sender.note(60); + await until(() => heard.length >= 2); + assert.deepStrictEqual(heard, [['ProPresenter', 'noteon', 60], ['ProPresenter', 'noteoff', 60]]); + s.stop(); + sender.close(); + await wait(80); + }); + + await test('a config save re-inits on the same port straight away', async () => { + const senderPort = await freePair(); + const sender = await fakeSender(senderPort, 'Fake PP'); + const port = await freePair(); + const mod = require('./server'); + const config = { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort } }; + let handle = mod.init({ config, log: () => {} }); + await until(() => handle.health().status === 'ok'); + handle.stop(); + handle = mod.init({ config, log: () => {} }); // what the shell does on save: stop(), then init() + await until(() => handle.health().status === 'ok'); + handle.stop(); + sender.close(); + await wait(80); + }); + + await test('a port taken by someone else is reported, then taken once free', async () => { + const port = await freePair(); + const squatter = dgram.createSocket('udp4'); + await new Promise((r) => squatter.bind(port + 1, r)); + const s = new Session({ name: 'Test', port }); + let listening = false; + s.on('listening', () => { listening = true; }); + assert.strictEqual(await s.start(), false); + assert.match(s.error, /in use/); + squatter.close(); + await until(() => listening, 5000); + assert.strictEqual(s.error, ''); + s.stop(); + }); + + await test('server entry: notes become live cue lists, over HTTP', async () => { + const senderPort = await freePair(); + const sender = await fakeSender(senderPort, 'Fake PP'); + const port = await freePair(); + const mod = require('./server'); + const handle = mod.init({ + config: { + sessionPort: port, + advertise: false, + source1Name: 'ProPresenter', + source1: { host: '127.0.0.1', port: senderPort }, + cueLists: '60 = Walk-in\n61 = Worship 1\n70 release Walk-in\n127 release all', + }, + log: () => {}, + }); + const routes = mod.routes({}); + const server = http.createServer((req, res) => { + const r = routes[`${req.method} ${req.url}`]; + if (r) r(req, res); else { res.writeHead(404); res.end(); } + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const get = async (path, method = 'GET') => { + const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method }); + return res.json(); + }; + const state = async () => (await get('/state')).state; + + await until(() => sender.peers.length > 0); + await until(() => handle.health().status === 'ok'); + sender.note(60); + await wait(100); + sender.note(61); + await wait(100); + let st = await state(); + assert.strictEqual(st.current.name, 'Worship 1'); + assert.deepStrictEqual(st.active.map((l) => l.name), ['Walk-in']); + assert.strictEqual(st.current.source, 'ProPresenter'); + assert.strictEqual(st.sources[0].connected, true); + + sender.note(70); + sender.note(99); // unmapped + await wait(150); + st = await state(); + assert.deepStrictEqual(st.active, []); + assert.strictEqual(st.log[0].mapped, false); + assert.match(st.log[0].trigger, /Note 99/); + + sender.note(127); + await wait(100); + st = await state(); + assert.strictEqual(st.current, null); + + sender.note(60); + await wait(100); + await get('/clear', 'POST'); + st = await state(); + assert.strictEqual(st.current, null); + assert.strictEqual(st.log[0].action, 'cleared'); + handle.stop(); + + // Groups: a Go replaces its group's live list; the first group is the headline; flashes stay out. + const h2 = mod.init({ + config: { + sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort }, + cueLists: '[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n22 = Red\n23 = Blue\n30 flash Blinder Flash 1', + }, + log: () => {}, + }); + await until(() => h2.health().status === 'ok'); + for (const n of [0, 22, 23, 30]) { sender.note(n); await wait(60); } + await wait(100); + st = await state(); + assert.strictEqual(st.current.name, 'Pre Service'); + assert.deepStrictEqual(st.active.map((l) => [l.group, l.name]), [['Color', 'Blue']]); // Blue replaced Red + assert.strictEqual(st.log[0].action, 'flash'); + sender.note(8); // a new main scene takes over the look… + await wait(100); + st = await state(); + assert.strictEqual(st.current.name, 'Message'); + assert.deepStrictEqual(st.active, []); + sender.note(22); // …and FX fired after it layer on top again + await wait(100); + st = await state(); + assert.deepStrictEqual(st.active.map((l) => l.name), ['Red']); + h2.stop(); + + server.close(); + sender.close(); + await wait(80); + }); + + console.log(`\n${passed} passed`); + process.exit(0); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tools/vista-midi-mock.js b/tools/vista-midi-mock.js new file mode 100644 index 0000000..6a2f3bc --- /dev/null +++ b/tools/vista-midi-mock.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +'use strict'; + +/** + * A stand-in for the ProPresenter Mac's network MIDI session, for the Vista + * Cues module: an RTP-MIDI (AppleMIDI) session that accepts invitations and + * plays a cue sheet's notes to everyone in it, the way ProPresenter's MIDI + * actions reach Vista. + * + * node tools/vista-midi-mock.js 5104 # wait to be invited on UDP 5104/5105 + * node tools/vista-midi-mock.js 5104 --invite 127.0.0.1:5040 + * # …or invite ProdDash yourself + * --name "Tracks Laptop" session name (default "ProPresenter Mock") + * --every 4 seconds between cues (default 4) + * --channel 1 MIDI channel (default 1) + * --notes 2,23,8,30 play these notes in a loop instead of the built-in sheet's + * + * Point Vista Cues' "First sender" at 127.0.0.1 port 5104 and paste the + * sheet this prints into "Cue lists". Kill it to watch the tile go red; + * start it again to watch it come back. + * + * Written separately from the module's own rtpmidi.js on purpose, so a bug + * there is not mirrored here. + */ + +const dgram = require('dgram'); + +const argv = process.argv.slice(2); +const opt = (name, dflt) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 5104; +const NAME = opt('name', 'ProPresenter Mock'); +const EVERY = Number(opt('every', 4)) * 1000; +const CHANNEL = Math.min(16, Math.max(1, Number(opt('channel', 1)))) - 1; +const INVITE = opt('invite', ''); + +const SHEET = [ + '60 = Walk-in', + '61 = Worship 1', + '62 = Worship 2', + '63 = Message', + '64 pause Worship 2', + '70 release Walk-in', + '71 release Worship 1', + '127 release all', +]; +const NOTES = opt('notes', ''); +const SCRIPT = NOTES ? NOTES.split(',').map(Number).filter((n) => n >= 0 && n <= 127) : [60, 61, 70, 62, 64, 64, 71, 90, 63, 127]; + +const ssrc = (Math.random() * 0xffffffff) >>> 0; +const t0 = process.hrtime.bigint(); +const now = () => (process.hrtime.bigint() - t0) / 100000n; +let seq = Math.floor(Math.random() * 0xffff); + +const peers = new Map(); // ssrc → { address, controlPort, dataPort, token, name, ready } + +function session(cmd, token, name) { + const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`); + const b = Buffer.alloc(16 + n.length); + b.writeUInt16BE(0xffff, 0); + b.write(cmd, 2, 'ascii'); + b.writeUInt32BE(2, 4); + b.writeUInt32BE(token >>> 0, 8); + b.writeUInt32BE(ssrc, 12); + n.copy(b, 16); + return b; +} + +function ck(count, ts) { + const b = Buffer.alloc(36); + b.writeUInt16BE(0xffff, 0); + b.write('CK', 2, 'ascii'); + b.writeUInt32BE(ssrc, 4); + b[8] = count; + ts.forEach((t, i) => b.writeBigUInt64BE(BigInt.asUintN(64, t), 12 + i * 8)); + return b; +} + +/** One RTP-MIDI packet: note on, then (delta 0, running status) the note off. */ +function notePacket(note) { + const midi = Buffer.from([0x90 | CHANNEL, note, 100, 0x00, note, 0x00]); + const b = Buffer.alloc(12 + 1 + midi.length); + b[0] = 0x80; + b[1] = 0x61; + seq = (seq + 1) & 0xffff; + b.writeUInt16BE(seq, 2); + b.writeUInt32BE(Number(now() & 0xffffffffn), 4); + b.writeUInt32BE(ssrc, 8); + b[12] = midi.length; // B=0 J=0 Z=0 P=0, LEN + midi.copy(b, 13); + return b; +} + +const control = dgram.createSocket('udp4'); +const data = dgram.createSocket('udp4'); + +function onPacket(sock, which) { + return (buf, r) => { + if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffff) return; + const cmd = buf.toString('ascii', 2, 4); + if (cmd === 'IN') { + const token = buf.readUInt32BE(8); + const them = buf.readUInt32BE(12); + const end = buf.indexOf(0, 16); + const name = buf.toString('utf8', 16, end < 0 ? buf.length : end); + sock.send(session('OK', token, NAME), r.port, r.address); + const p = peers.get(them) || { address: r.address, token, name, ready: false }; + if (which === 'control') p.controlPort = r.port; + else { p.dataPort = r.port; p.ready = true; console.log(`[mock] "${name}" joined from ${r.address}`); } + peers.set(them, p); + } else if (cmd === 'OK') { + // Our own invitation (--invite) was accepted. + const them = buf.readUInt32BE(12); + const end = buf.indexOf(0, 16); + const name = buf.toString('utf8', 16, end < 0 ? buf.length : end); + if (which === 'control') { + pending.stage = 'data'; + data.send(session('IN', pending.token, NAME), pending.port + 1, pending.host); + } else { + pending.stage = 'done'; + peers.set(them, { address: r.address, controlPort: pending.port, dataPort: r.port, token: pending.token, name, ready: true }); + console.log(`[mock] joined "${name}" at ${r.address}:${pending.port}`); + } + } else if (cmd === 'BY') { + const them = buf.readUInt32BE(12); + if (peers.delete(them)) console.log('[mock] a participant left'); + } else if (cmd === 'CK') { + const count = buf[8]; + const ts = [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)]; + if (count === 0) data.send(ck(1, [ts[0], now(), 0n]), r.port, r.address); + else if (count === 1) data.send(ck(2, [ts[0], ts[1], now()]), r.port, r.address); + } + }; +} + +const pending = { stage: '', host: '', port: 0, token: 0 }; + +control.on('message', onPacket(control, 'control')); +data.on('message', onPacket(data, 'data')); +control.bind(PORT, () => data.bind(PORT + 1, () => { + console.log(`[mock] session "${NAME}" on UDP ${PORT}/${PORT + 1}, channel ${CHANNEL + 1}`); + console.log('[mock] cue sheet for Vista Cues → Cue lists:\n ' + SHEET.join('\n ')); + if (INVITE) { + const [host, port] = INVITE.split(':'); + Object.assign(pending, { stage: 'control', host, port: Number(port) || 5040, token: (Math.random() * 0xffffffff) >>> 0 }); + const tryInvite = () => { + if (pending.stage === 'done') return; + pending.stage = 'control'; + control.send(session('IN', pending.token, NAME), pending.port, pending.host); + setTimeout(tryInvite, 2000); + }; + tryInvite(); + } +})); + +// Clock sync, as the session that started it would do (harmless otherwise). +setInterval(() => { + for (const p of peers.values()) if (p.ready) data.send(ck(0, [now(), 0n, 0n]), p.dataPort, p.address); +}, 10000); + +let step = 0; +setInterval(() => { + const ready = [...peers.values()].filter((p) => p.ready); + if (!ready.length) return; + const note = SCRIPT[step % SCRIPT.length]; + step += 1; + const pkt = notePacket(note); + for (const p of ready) data.send(pkt, p.dataPort, p.address); + console.log(`[mock] note ${note} → ${ready.length} participant${ready.length === 1 ? '' : 's'}`); +}, EVERY); + +process.on('SIGINT', () => { + for (const p of peers.values()) control.send(session('BY', p.token), p.controlPort, p.address); + setTimeout(() => process.exit(0), 100); +}); From 4c1c7f565f5e184f037498b84d2c08d599b46a18 Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 11 Oct 2026 11:39:53 -0400 Subject: [PATCH 08/23] =?UTF-8?q?Vista=20Cues=201.0.1:=20its=20own=20headi?= =?UTF-8?q?ng=20in=20the=20=EF=BC=8B=20picker=20instead=20of=20sitting=20u?= =?UTF-8?q?nder=20Slack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/vista-cues/module.json | 2 +- modules/vista-cues/server.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/modules/vista-cues/module.json b/modules/vista-cues/module.json index a384a34..7021f7c 100644 --- a/modules/vista-cues/module.json +++ b/modules/vista-cues/module.json @@ -1,7 +1,7 @@ { "id": "vista-cues", "name": "Vista Cues", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.5.0", "description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista", "permissions": [ diff --git a/modules/vista-cues/server.js b/modules/vista-cues/server.js index 4e29938..9c04c10 100644 --- a/modules/vista-cues/server.js +++ b/modules/vista-cues/server.js @@ -323,6 +323,22 @@ module.exports = { }; }, + /** + * 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' }); From 4b0f161c9370528238d3bf7aace75ecaebfd3271 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 13 Oct 2026 11:03:31 -0400 Subject: [PATCH 09/23] Band Lineup 1.11.1: Sync replaces last week's hand-placed people MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync only filled empty spots and spots it had filled itself, so anyone dragged in by hand stayed forever — pressing Sync for a new week swapped some photos and left others, depending on how each spot got filled. Hand placements are now stamped with the plan they were made for. Sync keeps the ones made for the plan it is loading (a sub dragged in this week survives a re-sync) and replaces or clears the rest. The last synced plan is saved so placements made after a restart stamp right. Co-Authored-By: Claude Opus 5.5 --- modules/band-lineup/module.json | 2 +- modules/band-lineup/server.js | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json index c144f92..df2cc60 100644 --- a/modules/band-lineup/module.json +++ b/modules/band-lineup/module.json @@ -1,7 +1,7 @@ { "id": "band-lineup", "name": "Band Lineup", - "version": "1.11.0", + "version": "1.11.1", "proddash": ">=1.4.0", "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill", "client": "client.js", diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index 4141122..92bcf63 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -247,7 +247,7 @@ module.exports = { return { configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId), reachable: false, lastError: '', lastFetched: 0, columns, - plan: null, + plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right slots: effectiveSlots().map((label, index) => ({ index, label, person: null })), library: [], choir: choirOn, }; @@ -493,7 +493,8 @@ module.exports = { await syncPhotos(); // import the photo source (Drive folder or local path) on every sync if (pco.configured && serviceTypeId) { const plan = pickPlan(await listPlans(serviceTypeId)); - latest.plan = plan ? { title: plan.title, dates: plan.dates } : null; + latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null; + if (plan) { settings.lastPlan = latest.plan; saveSettings(); } if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; } else { @@ -521,12 +522,15 @@ module.exports = { const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })); - // Slots a person is sitting in by hand — don't fill them, and don't place that same person twice. + // Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that + // same person twice. A hand placement made for an earlier plan (last week's lineup, or one + // from before placements were stamped) is stale: Sync replaces it like any synced slot. const eff = effectiveSlots(); + const planId = String(plan.id); const openIdx = []; const manualNames = new Set(); for (let i = 0; i < eff.length; i += 1) { const e = board[String(i)]; - if (e && (e.personId || e.name)) { + if (e && (e.personId || e.name) && e.plan === planId) { const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name; if (nm) manualNames.add(normName(nm)); } else openIdx.push(i); @@ -540,8 +544,8 @@ module.exports = { const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl); board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } }; - } else if (board[String(i)] && board[String(i)].pco) { - delete board[String(i)]; // was synced, no longer on the plan + } else if (board[String(i)]) { + delete board[String(i)]; // synced or stale, and no longer on the plan } } saveBoard(); @@ -580,6 +584,7 @@ module.exports = { current = { streams, getLatest: () => latest, pco, listPlans, photoDir, library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots, + planId: () => (latest.plan && latest.plan.id) || '', photoUrlFor, libraryList, setChoir, choirOn: () => choirOn, }; @@ -684,7 +689,7 @@ module.exports = { const personId = String(q.get('personId') || '').trim(); if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' }); if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' }); - if (personId) current.board[slot] = { personId, name: '' }; + if (personId) current.board[slot] = { personId, name: '', plan: current.planId() }; else delete current.board[slot]; current.saveBoard(); current.rebuildLocal(); json(res, 200, { ok: true }); @@ -702,6 +707,7 @@ module.exports = { const name = String(q.get('name') || '').trim(); const existing = current.board[index] || { personId: '', name: '' }; existing.name = name; + existing.plan = current.planId(); if (!existing.personId && !name) delete current.board[index]; else current.board[index] = existing; current.saveBoard(); current.rebuildLocal(); From a692b6a9e284a2e51f8699a245f9d6ee2ee00324 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:06:57 -0400 Subject: [PATCH 10/23] Band Lineup 1.12.0: dragging photos into spots works with a finger and stops dropping out The editor used the browser's built-in drag-and-drop, which never fires for a touchscreen, flickered its drop highlight over a card's inner parts, and lost the drag whenever a board update arrived mid-drag (the editor was rebuilt under the pointer). Dragging is now driven by pointer events, one path for mouse and touch: a mouse drags as soon as it moves, a finger holds still for a moment first so a swipe still scrolls. A floating copy of the photo follows the pointer, the spot under it lights up, the editor auto-scrolls near its edges, and board updates wait until the drop. Tap a photo then tap a spot still works. Co-Authored-By: Claude Opus 5.5 --- modules/band-lineup/client.js | 158 ++++++++++++++++++++++---------- modules/band-lineup/module.json | 2 +- modules/band-lineup/style.css | 17 ++++ 3 files changed, 129 insertions(+), 48 deletions(-) diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js index ab86bc8..1e0b2d8 100644 --- a/modules/band-lineup/client.js +++ b/modules/band-lineup/client.js @@ -1,5 +1,5 @@ // Band Lineup — client. Renders the shared stage board from /state + /stream. -// The Edit button opens an editor: an uploaded-photo library you drag (or click) +// The Edit button opens an editor: an uploaded-photo library you drag (or tap) // into slots, plus rename / clear / upload / delete. Edits go to the server and // come back over SSE, so every Band Lineup screen stays in sync. Birthday week // (Wed→Tue) rains confetti. @@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) { let selectedLibId = ''; // click-to-place fallback for touch const canvases = new Set(); let raf = null; - // Auto-scroll the editor while dragging a photo (native DnD won't do it reliably). - let dragScrolling = false; - let dragY = 0; - let dragBody = null; - let dragRaf = null; - function startDragAutoScroll() { - if (dragRaf) return; - const tick = () => { - if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; } - const r = dragBody.getBoundingClientRect(); - const zone = 90; // generous edge band so you don't have to be pixel-accurate - let dy = 0; - if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3; - else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3; - if (dy) dragBody.scrollTop += dy; - dragRaf = requestAnimationFrame(tick); - }; - dragRaf = requestAnimationFrame(tick); + /* ---------- drag (pointer events: one path for mouse, pen and touch) ---------- + * Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements + * flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This + * drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for + * a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo + * follows the pointer, the spot under it lights up, and the editor auto-scrolls near its + * edges. Board updates that arrive during a drag wait until it ends. */ + const DRAG_MOVE_PX = 6; + const TOUCH_HOLD_MS = 220; + let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf } + let pending = null; // a pointerdown that may become a drag + let renderHeld = false; // a state update arrived mid-drag; render when it ends + + function slotUnder(x, y) { + const hit = document.elementFromPoint(x, y); + const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null; + return cell && root.contains(cell) ? cell : null; } - function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } } + function beginDrag(p) { + const ghost = el('div', 'bl-drag-ghost'); + ghost.dataset.module = 'band-lineup'; // lives on (above every tile), so it carries its own scope + const src = p.source.querySelector('img'); + if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); } + else ghost.textContent = p.label || ''; + document.body.appendChild(ghost); + drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source }; + p.source.classList.add('bl-dragging'); + root.classList.add('bl-is-dragging'); + moveDrag(p.x, p.y); + const tick = () => { + if (!drag) return; + const b = drag.body; + if (b && b.isConnected) { + const r = b.getBoundingClientRect(); const zone = 70; let dy = 0; + if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2; + else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2; + if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); } + } + drag.raf = requestAnimationFrame(tick); + }; + drag.raf = requestAnimationFrame(tick); + } + function moveDrag(x, y) { + drag.x = x; drag.y = y; + drag.ghost.style.transform = `translate(${x}px, ${y}px)`; + const cell = slotUnder(x, y); + if (cell !== drag.over) { + if (drag.over) drag.over.classList.remove('bl-drop-over'); + drag.over = cell; + if (cell) cell.classList.add('bl-drop-over'); + } + } + function endDrag(drop) { + const d = drag; drag = null; + if (!d) return; + if (d.raf) cancelAnimationFrame(d.raf); + d.ghost.remove(); + d.source.classList.remove('bl-dragging'); + root.classList.remove('bl-is-dragging'); + if (d.over) d.over.classList.remove('bl-drop-over'); + if (drop && d.over) { + const to = d.over.dataset.index; + if (d.kind === 'lib') assign(to, d.id); + else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' }); + } + if (renderHeld) { renderHeld = false; render(); } + } + function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; } + // Make `node` draggable. `kind`/`id` say what is being dragged. + function makeDraggable(node, kind, id, body, label) { + node.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || drag) return; + if (e.target.closest('button')) return; // name / clear / delete buttons keep working + cancelPending(); + pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null }; + if (pending.touch) { + const p = pending; + p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS); + } + }); + } + // Document-level move/up so the drag survives leaving the card it started on. + function onPointerMove(e) { + if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; } + if (!pending || e.pointerId !== pending.pointerId) return; + const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy); + if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll + if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); } + } + function onPointerUp(e) { + if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); } + cancelPending(); + } + let suppressClick = false; // the click that follows a drop must not also select / place + // While a finger drag is live, stop the page from scrolling under it. + function onTouchMove(e) { if (drag) e.preventDefault(); } + document.addEventListener('pointermove', onPointerMove, { passive: false }); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', onPointerUp); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + function stopDragAutoScroll() { cancelPending(); endDrag(false); } /* ---------- confetti (always animates) ---------- */ function makeParticle(w, h, intensity, seedY) { @@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) { function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } } function render() { + if (drag) { renderHeld = true; return; } stopConfetti(); stopParallax(); root.innerHTML = ''; @@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) { // board on top and the picture picker (library) below it. const wrap = el('div', 'bl-editor bl-editor-nobar'); const body = el('div', 'bl-editor-body'); - // Feed the drag auto-scroller the pointer position anywhere over the editor. - body.addEventListener('dragover', (e) => { dragY = e.clientY; }); // Slots (drop targets) const boardEl = el('div', 'bl-edit-board'); @@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) { photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+')); cell.appendChild(photo); // An occupied card is a drag SOURCE too — move/swap it to another slot. - if (slot.person) { - cell.draggable = true; - cell.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move'; - dragBody = body; dragScrolling = true; startDragAutoScroll(); - }); - cell.addEventListener('dragend', stopDragAutoScroll); - } + if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name); const nameRow = el('div', 'bl-edit-namerow'); const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…'); nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : '')); @@ -191,16 +264,7 @@ export default function create({ root, moduleApi }) { if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); } cell.appendChild(nameRow); cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`)); - // drop - cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); }); - cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over')); - cell.addEventListener('drop', (e) => { - e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over'); - const data = e.dataTransfer.getData('text/plain') || ''; - if (data.startsWith('lib:')) assign(slot.index, data.slice(4)); - else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); } - }); - cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); + cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); boardEl.appendChild(cell); } body.appendChild(boardEl); @@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) { const list = state?.library || []; if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.')); for (const person of list) { - const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id; + const item = el('div', 'bl-lib-item'); item.dataset.id = person.id; if (person.id === selectedLibId) item.classList.add('bl-lib-selected'); const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph); const nm = el('button', 'bl-lib-name', person.name || 'Name…'); @@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) { const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo'; del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); }); item.appendChild(del); - item.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy'; - dragBody = body; dragScrolling = true; startDragAutoScroll(); - }); - item.addEventListener('dragend', stopDragAutoScroll); - item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); + makeDraggable(item, 'lib', person.id, body, person.name); + item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); lib.appendChild(item); } body.appendChild(lib); @@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) { stopConfetti(); stopParallax(); stopDragAutoScroll(); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', onPointerUp); + document.removeEventListener('touchmove', onTouchMove); root.innerHTML = ''; }, }; diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json index df2cc60..56e431f 100644 --- a/modules/band-lineup/module.json +++ b/modules/band-lineup/module.json @@ -1,7 +1,7 @@ { "id": "band-lineup", "name": "Band Lineup", - "version": "1.11.1", + "version": "1.12.0", "proddash": ">=1.4.0", "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill", "client": "client.js", diff --git a/modules/band-lineup/style.css b/modules/band-lineup/style.css index 68fe5bf..55214f1 100644 --- a/modules/band-lineup/style.css +++ b/modules/band-lineup/style.css @@ -230,3 +230,20 @@ background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1; } [data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; } + +/* Drag: the floating photo that follows the pointer (appended to , so it scopes itself). */ +[data-module="band-lineup"].bl-drag-ghost { + position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none; + width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden; + border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px; + display: flex; align-items: center; justify-content: center; text-align: center; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92; +} +[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; } +[data-module="band-lineup"] .bl-lib-item, +[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } +[data-module="band-lineup"] .bl-edit-slot img, +[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; } +[data-module="band-lineup"] .bl-dragging { opacity: 0.35; } +[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; } +[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; } From c1152b4ab3ac101cfd7b18eeb7c7518c973bbd0b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:11:56 -0400 Subject: [PATCH 11/23] Band Lineup 1.12.0: Sync keeps your moves and puts each person in one spot Two Sync problems found by checking the real October 25 plan against the board: - Someone on the plan twice (Mike Powell is Leader and Guitar [Acoustic]) could fill two spots, pushing a real player off the board. Which spot the duplicate took depended on the order Planning Center returned people, so it only happened some weeks. Assignment now uses each person once. - Moving a synced person to another spot was undone by the next Sync, which re-placed everyone it had filled. A move is now a hand placement for the current plan and Sync leaves it alone. Co-Authored-By: Claude Opus 5.5 --- modules/band-lineup/matching.js | 6 +++++- modules/band-lineup/matching.test.js | 9 +++++++++ modules/band-lineup/server.js | 8 ++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index cfea022..a12701d 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -128,7 +128,11 @@ function assignMembersToSlots(slots, members) { if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex < 0) return null; - return pool.splice(bestIndex, 1)[0]; + const chosen = pool.splice(bestIndex, 1)[0]; + // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. + const key = chosen.personId || ''; + if (key) for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i].personId === key) pool.splice(i, 1); + return chosen; }); } diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 516574c..90f30c8 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -65,3 +65,12 @@ console.log('assignment:', JSON.stringify(out)); assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null'); console.log('bestLibraryMatch: all assertions passed ✓'); } + +// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only --- +{ + const P = (label, name, personId) => ({ label, name, personId }); + const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7']; + const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null)); + assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot'); + console.log('one spot per person: all assertions passed ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index 92bcf63..abc1057 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -530,8 +530,8 @@ module.exports = { const openIdx = []; const manualNames = new Set(); for (let i = 0; i < eff.length; i += 1) { const e = board[String(i)]; - if (e && (e.personId || e.name) && e.plan === planId) { - const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name; + if (e && (e.personId || e.name || e.pco) && e.plan === planId) { + const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name; if (nm) manualNames.add(normName(nm)); } else openIdx.push(i); } @@ -675,6 +675,10 @@ module.exports = { const from = String(p.get('from') || ''); const to = String(p.get('to') || ''); if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' }); const a = current.board[from]; const b = current.board[to]; + // Moving a spot is a hand placement for this plan: Sync must not move it back. + const plan = current.planId(); + if (a) a.plan = plan; + if (b) b.plan = plan; if (a === undefined) delete current.board[to]; else current.board[to] = a; if (b === undefined) delete current.board[from]; else current.board[from] = b; current.saveBoard(); current.rebuildLocal(); From 0548cb54fa43ec865b53c37aabde48debb7c7635 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:14:39 -0400 Subject: [PATCH 12/23] Band Lineup 1.12.0: Sync puts the leader and vocalists on the mic their Planning Center note names The booth writes each vocalist's and the leader's mic into their Planning Center note every week ("Mix 8, VOX 5", "Mix 1, Vox 1"), because those change week to week and the position name ("Vocalist") carries no number. Sync ignored the notes and dropped the singers onto Vox 2-5 in whatever order Planning Center listed them. Sync now reads each team member's note and places anyone whose note names a spot there first: the mix number must agree with the slot's MIX, and the role ("VOX 5", "Drums") is scored like a position. "NA" or a blank note says nothing. Everyone else is placed by position as before. Tests use the real Oct 25 and Nov 1 notes. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++--- modules/band-lineup/matching.js | 62 +++++++++++++++++++++++++--- modules/band-lineup/matching.test.js | 29 +++++++++++++ modules/band-lineup/server.js | 1 + 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 614b85c..53cb9f7 100644 --- a/README.md +++ b/README.md @@ -521,11 +521,16 @@ below. Nothing is installed without someone clicking. A photo library fills it: point the module at a Google Drive folder shared "anyone with the link" (file name → person's name) or at a folder on the ProdDash machine, and photos added or removed there follow. **Edit** in the - title bar opens that library — drag a photo onto a slot, drag between slots - to swap, rename or clear. Planning Center is optional auto-fill on top, not - the source of truth: **Sync** reads the upcoming plan's team members and - drops each into the slot matching their position, filling only slots nobody - placed by hand, so a guest pinned on Thursday survives Sunday's sync. + title bar opens that library — drag a photo onto a slot (mouse, or press and + hold on a touchscreen), drag between slots to swap, rename or clear. + Planning Center is optional auto-fill on top, not the source of truth: + **Sync** reads the upcoming plan's team members and places each one. A + person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to + that slot — this is how the leader and vocalists, whose position is just + "Vocalist", land on the right mic — and everyone else goes to the slot + matching their position, one slot per person. Anything placed or moved by + hand for that plan stays put through later syncs; last week's people are + replaced. Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and raining confetti, and a Choir button adds the extra spot for the weeks there is one. Admin config: Planning Center credentials, service type and plan, the diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index a12701d..e5e1180 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) { * member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot. */ const MIN_SCORE = 60; + +/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */ +function mixNumbers(value) { + const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m; + while ((m = re.exec(String(value || '')))) out.add(m[1]); + return out; +} + +/** + * How well a person's Planning Center note names a spot. The booth writes the mic/role into each + * vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those + * change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a + * different mix than the spot is never a match; the role part ("VOX 5") is scored like a position. + * 0 means the note says nothing usable about this spot ("NA", blank, or no overlap). + */ +function noteScore(slotLabel, note) { + const text = String(note || '').trim(); + if (!text || /^n\/?a$/i.test(text)) return 0; + const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text); + let mixBonus = 0; + if (noteMix.size && slotMix.size) { + if (![...noteMix].some((n) => slotMix.has(n))) return 0; + mixBonus = 100; + } + const role = text.replace(/\bmix\s*\d+\b/gi, ' '); + const roleScore = scoreMatch(slotRoleText(slotLabel), role); + if (!roleScore && !mixBonus) return 0; + if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) { + // The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither. + const roleTokens = labelTokens(role); + if (roleTokens.length) return 0; + } + return mixBonus + roleScore; +} + function assignMembersToSlots(slots, members) { const pool = members.slice(); - return slots.map((slotLabel) => { + const result = slots.map(() => null); + const take = (chosen) => { + // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. + const key = chosen.personId || ''; + for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1); + }; + // Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first. + const pairs = []; + slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); })); + pairs.sort((a, b) => b.sc - a.sc); + for (const { s, m } of pairs) { + if (result[s] || !pool.includes(m)) continue; + result[s] = m; take(m); + } + // Pass 2 — everyone else by Planning Center position, into the spots still open. + return result.map((placed, s) => { + if (placed) return placed; + const slotLabel = slots[s]; const role = slotRoleText(slotLabel); let bestIndex = -1; let bestScore = MIN_SCORE - 1; @@ -128,10 +180,8 @@ function assignMembersToSlots(slots, members) { if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex < 0) return null; - const chosen = pool.splice(bestIndex, 1)[0]; - // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. - const key = chosen.personId || ''; - if (key) for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i].personId === key) pool.splice(i, 1); + const chosen = pool[bestIndex]; + take(chosen); return chosen; }); } @@ -198,5 +248,7 @@ module.exports = { numbersIn, scoreMatch, assignMembersToSlots, + noteScore, + mixNumbers, MIN_SCORE, }; diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 90f30c8..d68e859 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -74,3 +74,32 @@ console.log('assignment:', JSON.stringify(out)); assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot'); console.log('one spot per person: all assertions passed ✓'); } + +// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) --- +{ + const { noteScore } = require('./matching'); + const NA_SLOTS = [ + '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5', + '[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]', + ]; + const P = (label, name, personId, note) => ({ label, name, personId, note }); + // Listed in the order Planning Center returned them — vocalists alphabetical, not by mic. + const oct25 = [ + P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'), + P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'), + P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'), + P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'), + ]; + const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null)); + assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes'); + // Nov 1: leader note in different case, a drum note with no mix, an "NA" note. + const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')]; + assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums'); + assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]'); + // A note never pulls someone onto a different mix. + assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2'); + assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8'); + assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing'); + assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10'); + console.log('notes: all assertions passed ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index abc1057..5c3b641 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -512,6 +512,7 @@ module.exports = { personId, name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown', label: String(m.attributes?.team_position_name || '').trim(), + note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(), }); if (personId) seen.add(personId); From df2f0494f8f95a2cf1ffd8ec083054952934bad9 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:14:44 -0400 Subject: [PATCH 13/23] 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 --- modules/vista-cues/client.js | 4 ++- modules/vista-cues/module.json | 9 ++++- modules/vista-cues/server.js | 59 +++++++++++++++++++++++++++++++- modules/vista-cues/style.css | 2 ++ modules/vista-cues/vista.test.js | 47 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) diff --git a/modules/vista-cues/client.js b/modules/vista-cues/client.js index 83861de..4a12066 100644 --- a/modules/vista-cues/client.js +++ b/modules/vista-cues/client.js @@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) { el.sources.hidden = !on('showSources') || !s.sources.length; if (!el.sources.hidden) { el.sources.innerHTML = s.sources.map((x) => ` - + ${esc(x.label)} `).join(''); } @@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) { 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); diff --git a/modules/vista-cues/module.json b/modules/vista-cues/module.json index 7021f7c..5ecd390 100644 --- a/modules/vista-cues/module.json +++ b/modules/vista-cues/module.json @@ -1,7 +1,7 @@ { "id": "vista-cues", "name": "Vista Cues", - "version": "1.0.1", + "version": "1.1.0", "proddash": ">=1.5.0", "description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista", "permissions": [ @@ -131,6 +131,13 @@ "default": true, "group": "This machine", "help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)." + }, + "wiredNetwork": { + "type": "string", + "label": "Wired network", + "default": "", + "group": "This machine", + "help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network." } }, "configGroups": { diff --git a/modules/vista-cues/server.js b/modules/vista-cues/server.js index 9c04c10..dab01d1 100644 --- a/modules/vista-cues/server.js +++ b/modules/vista-cues/server.js @@ -49,6 +49,44 @@ function emptyState(extra = {}) { }; } +/** + * The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs + * here are also on Wi-Fi and the Dante networks, and Bonjour lists each + * session once per network, so a sender typed or picked from the wrong copy + * works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }]; + * throws on a typo so the admin page says so at once. + */ +function parseNets(text) { + return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => { + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n); + const parts = m ? m.slice(1, 5).map(Number) : []; + const bits = m && m[5] !== undefined ? Number(m[5]) : 32; + if (!m || parts.some((x) => x > 255) || bits > 32) { + throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`); + } + return { base: ipNumber(parts.join('.')), bits, text: n }; + }); +} + +function ipNumber(a) { + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim()); + return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null; +} + +/** + * Why an address is not on the wired network: '' when it is (or there is no + * rule), 'by name' for a host name (it resolves to whichever network answers + * first), 'not wired' for an address outside every wired network. + */ +function offNetwork(nets, address) { + if (!nets.length || !address) return ''; + const v = ipNumber(address); + if (v === null) return 'by name'; + if (address.startsWith('127.')) return ''; + const span = (bits) => 2 ** (32 - bits); + return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired'; +} + function endpointOf(v) { const ep = v && typeof v === 'object' ? v : {}; return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 }; @@ -62,6 +100,8 @@ module.exports = { const exclusive = config.exclusive === true; const headTakesOver = config.headTakesOver !== false; const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC }); + const nets = parseNets(config.wiredNetwork); + const netText = nets.map((n) => n.text).join(', '); if (mappingErrors.length) { for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`); } @@ -187,6 +227,9 @@ module.exports = { label: s.label, name: p ? p.name : '', address: inv?.address || s.host, + // A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now. + offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''), + host: s.host, configured: true, connected: Boolean(p), error: p ? '' : (inv?.error || ''), @@ -195,7 +238,7 @@ module.exports = { } 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 }); + out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard }); } return out; } @@ -219,6 +262,7 @@ module.exports = { sessionName, port, advertised, + wiredNetwork: netText, sources: sources(), current: cur || null, // The rest in sheet order of their group, so a colour chip stays put as it changes. @@ -304,6 +348,16 @@ module.exports = { health() { if (session.error) return { status: 'error', message: session.error }; const src = sources(); + // Off the wired network: still joined (the cues keep working), but said first and in red. + const off = src.filter((s) => s.offNetwork); + if (off.length) { + return { + status: 'error', + message: `${off.map((s) => (s.offNetwork === 'by name' + ? `${s.label} is set by name (${s.host})` + : `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`, + }; + } 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'; @@ -323,6 +377,9 @@ module.exports = { }; }, + // For the tests. + _offNetwork: (text, address) => offNetwork(parseNets(text), address), + /** * 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 diff --git a/modules/vista-cues/style.css b/modules/vista-cues/style.css index b6eae02..2593188 100644 --- a/modules/vista-cues/style.css +++ b/modules/vista-cues/style.css @@ -180,6 +180,8 @@ } [data-module="vista-cues"] .vc-src.is-on { color: var(--text); } [data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); } +[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); } +[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); } @container (max-height: 140px) { [data-module="vista-cues"] .vc-sources { display: none; } } diff --git a/modules/vista-cues/vista.test.js b/modules/vista-cues/vista.test.js index 3b3f300..3093bc4 100644 --- a/modules/vista-cues/vista.test.js +++ b/modules/vista-cues/vista.test.js @@ -363,6 +363,53 @@ async function freePair() { await wait(80); }); + await test('wired network: addresses outside it, and names, are flagged', () => { + const mod = require('./server'); + const off = mod._offNetwork; + assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network + assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), ''); + assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi + assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante + assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name'); + assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine + assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several + assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address + assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/); + assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/); + }); + + await test('server entry: a sender off the wired network still joins, and says so', async () => { + const senderPort = await freePair(); + const sender = await fakeSender(senderPort, 'Fake Tracks'); + const port = await freePair(); + const mod = require('./server'); + assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/); + const handle = mod.init({ + config: { + sessionPort: port, + advertise: false, + wiredNetwork: '10.1.0.0/16', + source2Name: 'Tracks', + source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time + cueLists: '60 = Walk-in', + }, + log: () => {}, + }); + await until(() => sender.peers.length > 0); // joined all the same + const h = handle.health(); + assert.strictEqual(h.status, 'error'); + assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/); + const routes = mod.routes({}); + let body = ''; + await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } })); + const st = JSON.parse(body).state; + assert.strictEqual(st.wiredNetwork, '10.1.0.0/16'); + assert.strictEqual(st.sources[0].offNetwork, 'by name'); + handle.stop(); + sender.close(); + await wait(80); + }); + console.log(`\n${passed} passed`); process.exit(0); })().catch((e) => { From e59629694ecff1a96ab511cadcae609679fd0e75 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:28:49 -0400 Subject: [PATCH 14/23] Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos. Sync fell back to the Planning Center headshot whenever two library photos matched a person equally ("Mike" and "mike"), and had no way to know "Rose" is Rosaliz — so the booth dragged those in by hand every week. Now: - Several equally good library photos is not a reason to use PCO's; the first of them is used. PCO's headshot is only for someone with no photo. - Dropping a library photo onto the synced person already in that spot (same name, same first name, or a nickname sharing its first three letters) makes it their photo: they keep their Planning Center name and spot, and it is remembered (settings.json photoFor) for every later Sync. Photos already dragged in by hand for this plan are learned the same way on the next Sync. - A synced person's library photo is read live, so replacing that photo updates the board without a re-sync. Dragging. The editor was one scrolling column, so a photo found far down the list had to be carried back up to its spot, and every drop redrew the editor back to the top. The spots are now pinned in their own pane above the photos, each pane auto-scrolls when a drag nears its edge, both keep their scroll position through redraws, and a search box filters the photos by name. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++-- modules/band-lineup/client.js | 48 ++++++++++++++++++------- modules/band-lineup/matching.js | 25 +++++++++---- modules/band-lineup/matching.test.js | 17 +++++++-- modules/band-lineup/module.json | 2 +- modules/band-lineup/server.js | 52 +++++++++++++++++++++++----- modules/band-lineup/style.css | 14 ++++++++ 7 files changed, 137 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 53cb9f7..eae34dd 100644 --- a/README.md +++ b/README.md @@ -521,14 +521,21 @@ below. Nothing is installed without someone clicking. A photo library fills it: point the module at a Google Drive folder shared "anyone with the link" (file name → person's name) or at a folder on the ProdDash machine, and photos added or removed there follow. **Edit** in the - title bar opens that library — drag a photo onto a slot (mouse, or press and + title bar opens that library — the spots pinned on top, the photos (with a + name search) scrolling below — drag a photo onto a slot (mouse, or press and hold on a touchscreen), drag between slots to swap, rename or clear. + Dropping a photo onto the person already in a spot makes it *their* photo + (a nickname like "Rose" for Rosaliz counts): they keep their Planning Center + name and spot, and every later Sync uses that photo for them. Planning Center is optional auto-fill on top, not the source of truth: **Sync** reads the upcoming plan's team members and places each one. A person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to that slot — this is how the leader and vocalists, whose position is just "Vocalist", land on the right mic — and everyone else goes to the slot - matching their position, one slot per person. Anything placed or moved by + matching their position, one slot per person. Photos come from the library + whenever it has one of them — their remembered pick, else the best name + match, even when several photos fit — and Planning Center's headshot only + when it has none. Anything placed or moved by hand for that plan stays put through later syncs; last week's people are replaced. Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js index 1e0b2d8..3e93c29 100644 --- a/modules/band-lineup/client.js +++ b/modules/band-lineup/client.js @@ -45,9 +45,10 @@ export default function create({ root, moduleApi }) { moveDrag(p.x, p.y); const tick = () => { if (!drag) return; - const b = drag.body; - if (b && b.isConnected) { - const r = b.getBoundingClientRect(); const zone = 70; let dy = 0; + // Auto-scroll whichever pane (spots or photos) the pointer is near the edge of. + const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; }); + if (b && b.scrollHeight > b.clientHeight) { + const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0; if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2; else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2; if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); } @@ -163,9 +164,9 @@ export default function create({ root, moduleApi }) { if (drag) { renderHeld = true; return; } stopConfetti(); stopParallax(); - root.innerHTML = ''; if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir)); - if (editing) { renderEditor(); return; } + if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus + root.innerHTML = ''; const board = el('div', 'bl-board'); if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; } @@ -240,11 +241,16 @@ export default function create({ root, moduleApi }) { } /* ---------- editor ---------- */ + let libFilter = ''; // the photo search box, kept across re-renders function renderEditor() { - // No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the - // board on top and the picture picker (library) below it. + // No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes + // that scroll on their own: the spots pinned on top, the photos below — so a photo found far + // down the list never has to be carried back up to its spot. Both panes keep their scroll + // position (and the search box its focus) when a board update redraws the editor. + const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane'); + const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') }; const wrap = el('div', 'bl-editor bl-editor-nobar'); - const body = el('div', 'bl-editor-body'); + const body = el('div', 'bl-editor-body bl-editor-split'); // Slots (drop targets) const boardEl = el('div', 'bl-edit-board'); @@ -267,9 +273,15 @@ export default function create({ root, moduleApi }) { cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); }); boardEl.appendChild(cell); } - body.appendChild(boardEl); + const boardPane = el('div', 'bl-pane bl-board-pane'); + boardPane.appendChild(boardEl); + body.appendChild(boardPane); - // Library + // Library, with a name search + const libPane = el('div', 'bl-pane bl-lib-pane'); + const search = el('input', 'bl-lib-search'); + search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter; + libPane.appendChild(search); const lib = el('div', 'bl-library'); const list = state?.library || []; if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.')); @@ -287,11 +299,23 @@ export default function create({ root, moduleApi }) { item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); }); lib.appendChild(item); } - body.appendChild(lib); + const applyFilter = () => { + const q = libFilter.trim().toLowerCase(); + for (const item of lib.querySelectorAll('.bl-lib-item')) { + const nm = item.querySelector('.bl-lib-name'); + item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none'; + } + }; + search.addEventListener('input', () => { libFilter = search.value; applyFilter(); }); + applyFilter(); + libPane.appendChild(lib); + body.appendChild(libPane); wrap.appendChild(body); root.innerHTML = ''; root.appendChild(wrap); + boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib; + if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } } } function assign(slotIndex, personId) { @@ -320,7 +344,7 @@ export default function create({ root, moduleApi }) { title: 'Sync', onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); }, }); - const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } }); + const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } }); // Columns −/+ (touch-friendly; replaces the gear number field). const MINUS_ICON = ''; const PLUS_ICON = ''; diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index e5e1180..550c13f 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -189,8 +189,9 @@ function assignMembersToSlots(slots, members) { /** * Best library photo for a Planning Center person, matched by name so Sync can use the folder * photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files, - * preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS - * match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used. + * preferring the most specific. Returns the entry, or null when no library photo is a confident + * match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is + * not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos). * `entries` is [{ ...whatever, name }]. */ function bestLibraryMatch(pcoName, entries) { @@ -227,17 +228,29 @@ function bestLibraryMatch(pcoName, entries) { }; let best = null; let bestScore = 0; - let tie = false; for (const e of entries) { const s = scoreOf(e.name); - if (s > bestScore) { bestScore = s; best = e; tie = false; } - else if (s === bestScore && s > 0) tie = true; + if (s > bestScore) { bestScore = s; best = e; } } - if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo + if (bestScore < 60) return null; // no confident match → PCO photo return best; } +/** + * Could this library photo be of this Planning Center person? Used when someone drops a photo onto + * a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here". + * A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their + * first three letters (a nickname: "Rose" → "Rosaliz"). + */ +function samePerson(pcoName, libName) { + if (bestLibraryMatch(pcoName, [{ name: libName }])) return true; + const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = normalizeLabel(libName).split(' ')[0] || ''; + if (a && a === b) return true; + return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3); +} + module.exports = { + samePerson, normalizeTeamName, isAllowedTeamName, bestLibraryMatch, diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index d68e859..933ae79 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -61,8 +61,9 @@ console.log('assignment:', JSON.stringify(out)); assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person'); // exact full name beats a first-name photo assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins'); - // tie → no match (two identical-scoring photos) - assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null'); + // two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's + assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo'); + assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot'); console.log('bestLibraryMatch: all assertions passed ✓'); } @@ -103,3 +104,15 @@ console.log('assignment:', JSON.stringify(out)); assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10'); console.log('notes: all assertions passed ✓'); } + +// --- samePerson: is a dropped photo of the person already in the spot? --- +{ + const { samePerson } = require('./matching'); + assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz'); + assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case'); + assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words'); + assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words'); + assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo'); + assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix'); + console.log('samePerson: all assertions passed ✓'); +} diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json index 56e431f..26f243f 100644 --- a/modules/band-lineup/module.json +++ b/modules/band-lineup/module.json @@ -1,7 +1,7 @@ { "id": "band-lineup", "name": "Band Lineup", - "version": "1.12.0", + "version": "1.13.0", "proddash": ">=1.4.0", "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill", "client": "client.js", diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index 5c3b641..e3aed0f 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -32,7 +32,7 @@ const path = require('path'); const os = require('os'); const crypto = require('crypto'); -const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching'); +const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson } = require('./matching'); const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); @@ -219,6 +219,20 @@ module.exports = { const settings = readJsonFile(settingsPath, {}) || {}; const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path) let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile + // The photo the booth picked for a Planning Center person (PCO person id → library id), learned + // when a library photo is dropped onto that person. Sync uses it before any name matching. + if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {}; + function rememberPhoto(pcoPersonId, libId) { + if (!pcoPersonId || !libId || settings.photoFor[pcoPersonId] === libId) return; + settings.photoFor[pcoPersonId] = libId; saveSettings(); + } + // Library photo for a scheduled person: their remembered pick, else the best name match. + function libraryPhotoFor(personId, name) { + const picked = settings.photoFor[personId]; + if (picked && library[picked] && library[picked].file) return { id: picked, ...library[picked] }; + const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }))); + return m && m.file ? m : null; + } function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } } const CHOIR_LABEL = 'CHOIR'; @@ -449,11 +463,13 @@ module.exports = { if (!e) return null; let name = '', file = '', birthday = '', manual = true, personId = ''; if (e.pco) { - // The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is. + // A synced person. Their library photo (if any) is read live, so replacing that photo + // updates the board; otherwise the photo chosen at Sync time (a PCO headshot). manual = false; personId = e.pco.personId || ''; name = String(e.pco.name || '').trim(); - file = e.pco.file || ''; - birthday = e.pco.birthday || ''; + const lib = e.pco.libId ? library[e.pco.libId] : null; + file = (lib && lib.file) || e.pco.file || ''; + birthday = e.pco.birthday || (lib && lib.birthday) || ''; } else if (e.personId) { const lib = library[e.personId]; if (!lib) return null; // its library photo was deleted → slot is empty @@ -521,7 +537,18 @@ module.exports = { for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } } for (const m of members) m.birthday = bdays.get(m.personId) || ''; - const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })); + // A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled + // ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their + // Planning Center name, kept in that spot. + for (const [i, e] of Object.entries(board)) { + if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue; + const libName = library[e.personId].name; + const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName)); + if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement + const m = who[0]; + rememberPhoto(m.personId, e.personId); + board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId }, plan: e.plan }; + } // Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that // same person twice. A hand placement made for an earlier plan (last week's lineup, or one @@ -542,9 +569,9 @@ module.exports = { for (let k = 0; k < openIdx.length; k += 1) { const i = openIdx[k]; const m = assigned[k]; if (m) { - const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot - const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl); - board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } }; + const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot + const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl); + board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId } }; } else if (board[String(i)]) { delete board[String(i)]; // synced or stale, and no longer on the plan } @@ -586,6 +613,7 @@ module.exports = { streams, getLatest: () => latest, pco, listPlans, photoDir, library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots, planId: () => (latest.plan && latest.plan.id) || '', + rememberPhoto, photoUrlFor, libraryList, setChoir, choirOn: () => choirOn, }; @@ -694,7 +722,13 @@ module.exports = { const personId = String(q.get('personId') || '').trim(); if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' }); if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' }); - if (personId) current.board[slot] = { personId, name: '', plan: current.planId() }; + const here = current.board[slot]; + if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) { + // A photo of the person already in this spot: it's their photo now — this week and every + // Sync after. They keep their Planning Center name and their spot. + current.rememberPhoto(here.pco.personId, personId); + current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() }; + } else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() }; else delete current.board[slot]; current.saveBoard(); current.rebuildLocal(); json(res, 200, { ok: true }); diff --git a/modules/band-lineup/style.css b/modules/band-lineup/style.css index 55214f1..5f6c6b8 100644 --- a/modules/band-lineup/style.css +++ b/modules/band-lineup/style.css @@ -247,3 +247,17 @@ [data-module="band-lineup"] .bl-dragging { opacity: 0.35; } [data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; } [data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; } + +/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */ +[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; } +[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; } +[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); } +[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; } +[data-module="band-lineup"] .bl-lib-search { + display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px; + border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em; +} +[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); } +[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; } +[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; } +[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; } From 1fd7dcdf1e27c600f6acb58447856554ea7dcd7c Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:40:00 -0400 Subject: [PATCH 15/23] Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week DJ plays drums one week and sings the next; one photo can't fit both. A person can now have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for everything else. Sync reads the instrument family of the spot each person lands in (its bracketed role, else their PCO position) and picks: 1. the photo picked by hand for them on that instrument; 2. a photo named for that instrument; 3. their everyday pick, else their best-named photo. A photo of them on another instrument still beats PCO's headshot. Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad, Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo dropped onto someone is remembered per person per instrument ("|drums"), so a drum pick never overrides the vocal photo. A spot naming several roles files under its most specific: Leader, then Vocals, Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces left by "Name - Instrument". Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++--- modules/band-lineup/matching.js | 63 +++++++++++++++++++++++++--- modules/band-lineup/matching.test.js | 25 +++++++++++ modules/band-lineup/server.js | 46 ++++++++++++-------- 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index eae34dd..56bcd19 100644 --- a/README.md +++ b/README.md @@ -525,17 +525,22 @@ below. Nothing is installed without someone clicking. name search) scrolling below — drag a photo onto a slot (mouse, or press and hold on a touchscreen), drag between slots to swap, rename or clear. Dropping a photo onto the person already in a spot makes it *their* photo - (a nickname like "Rose" for Rosaliz counts): they keep their Planning Center - name and spot, and every later Sync uses that photo for them. + for that instrument (a nickname like "Rose" for Rosaliz counts): they keep + their Planning Center name and spot, and every later Sync uses it whenever + they are on that instrument. Planning Center is optional auto-fill on top, not the source of truth: **Sync** reads the upcoming plan's team members and places each one. A person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to that slot — this is how the leader and vocalists, whose position is just "Vocalist", land on the right mic — and everyone else goes to the slot matching their position, one slot per person. Photos come from the library - whenever it has one of them — their remembered pick, else the best name - match, even when several photos fit — and Planning Center's headshot only - when it has none. Anything placed or moved by + whenever it has one of them, and Planning Center's headshot only when it + has none. Someone who plays more than one thing can have a photo per + instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`; + Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and + a plain `DJ.jpg` for everything else: Sync uses the one for the spot they + are in that week. A photo dropped onto someone is remembered for that + instrument only. Anything placed or moved by hand for that plan stays put through later syncs; last week's people are replaced. Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index 550c13f..b2d7261 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -186,15 +186,60 @@ function assignMembersToSlots(slots, members) { }); } +/* ── photos by instrument ────────────────────────────────────────────── + * A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg", + * "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo + * whose instrument matches the spot the person is in that week. Instruments group into families so + * the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass, + * Guitar/Electric/Acoustic/Rhythm, Leader. */ +const ROLE_FAMILY = { + vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys', + drums: 'drums', percussion: 'drums', bass: 'bass', + guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader', +}; +/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */ +function roleFamilies(text) { + const out = new Set(); + for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); } + return out; +} +/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */ +function spotFamilies(slotLabel, positionLabel) { + const fromSlot = roleFamilies(slotRoleText(slotLabel)); + return fromSlot.size ? fromSlot : roleFamilies(positionLabel || ''); +} +/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}. + * The first word is always the person; instrument words after it are read off, other words + * ("action", "2") are just part of the photo's description. */ +function photoParts(libName) { + const words = normalizeLabel(libName).split(' ').filter(Boolean); + const roles = new Set(); const person = []; + words.forEach((w, i) => { + const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null; + if (f) roles.add(f); else if (!roles.size) person.push(w); + }); + return { person: person.join(' '), roles }; +} +/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick + * never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under + * its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */ +const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar']; +function photoKey(personId, families) { + const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || ''; + return f ? `${personId}|${f}` : String(personId || ''); +} + /** * Best library photo for a Planning Center person, matched by name so Sync can use the folder * photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files, * preferring the most specific. Returns the entry, or null when no library photo is a confident * match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is * not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos). - * `entries` is [{ ...whatever, name }]. + * `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the + * person is on this week: a photo for that instrument beats their everyday photo, which beats a + * photo of them on something else — but any photo of theirs still beats PCO's headshot. */ -function bestLibraryMatch(pcoName, entries) { +function bestLibraryMatch(pcoName, entries, want) { const target = normalizeLabel(pcoName); if (!target) return null; const T = target.split(' ').filter(Boolean); @@ -226,13 +271,17 @@ function bestLibraryMatch(pcoName, entries) { if (last && last.length >= 3 && T.includes(last)) return 78; return 0; }; + const wanted = want && want.size ? want : null; let best = null; let bestScore = 0; for (const e of entries) { - const s = scoreOf(e.name); + const parts = photoParts(e.name); + let s = scoreOf(parts.roles.size ? parts.person : e.name); + if (s < 60) continue; // not them + if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20; if (s > bestScore) { bestScore = s; best = e; } } - if (bestScore < 60) return null; // no confident match → PCO photo + if (!best) return null; // no confident match → PCO photo return best; } @@ -244,13 +293,17 @@ function bestLibraryMatch(pcoName, entries) { */ function samePerson(pcoName, libName) { if (bestLibraryMatch(pcoName, [{ name: libName }])) return true; - const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = normalizeLabel(libName).split(' ')[0] || ''; + const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || ''; if (a && a === b) return true; return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3); } module.exports = { samePerson, + roleFamilies, + spotFamilies, + photoParts, + photoKey, normalizeTeamName, isAllowedTeamName, bestLibraryMatch, diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 933ae79..1d65579 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -116,3 +116,28 @@ console.log('assignment:', JSON.stringify(out)); assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix'); console.log('samePerson: all assertions passed ✓'); } + +// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else --- +{ + const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching'); + // File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums". + const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }]; + const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; }; + assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo'); + assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo'); + assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo'); + assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo'); + assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family'); + assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO'); + assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument'); + assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed'); + assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored'); + assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument'); + assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person'); + assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position'); + assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar'); + assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums'); + assert.strictEqual(photoKey('dj', new Set()), 'dj'); + assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader'); + console.log('photos by instrument: all assertions passed ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index e3aed0f..60a6f63 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -32,7 +32,7 @@ const path = require('path'); const os = require('os'); const crypto = require('crypto'); -const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson } = require('./matching'); +const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching'); const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); @@ -219,19 +219,26 @@ module.exports = { const settings = readJsonFile(settingsPath, {}) || {}; const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path) let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile - // The photo the booth picked for a Planning Center person (PCO person id → library id), learned - // when a library photo is dropped onto that person. Sync uses it before any name matching. + // The photo the booth picked for a Planning Center person on an instrument ("|drums" + // → library id; a bare person id is their everyday pick), learned when a library photo is dropped + // onto that person. Sync uses it before any name matching. if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {}; - function rememberPhoto(pcoPersonId, libId) { - if (!pcoPersonId || !libId || settings.photoFor[pcoPersonId] === libId) return; - settings.photoFor[pcoPersonId] = libId; saveSettings(); + function rememberPhoto(key, libId) { + if (!key || !libId || settings.photoFor[key] === libId) return; + settings.photoFor[key] = libId; saveSettings(); } - // Library photo for a scheduled person: their remembered pick, else the best name match. - function libraryPhotoFor(personId, name) { - const picked = settings.photoFor[personId]; - if (picked && library[picked] && library[picked].file) return { id: picked, ...library[picked] }; - const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }))); - return m && m.file ? m : null; + // Library photo for a scheduled person in a spot whose instruments are `fams`: + // 1. the photo picked for them on this instrument; + // 2. a photo named for this instrument ("DJ - Drums"); + // 3. their everyday pick, else their best-named photo (an other-instrument photo of them + // still beats PCO's headshot). + function libraryPhotoFor(personId, name, fams) { + const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null); + const forRole = live(settings.photoFor[photoKey(personId, fams)]); + if (forRole) return forRole; + const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams); + if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m; + return live(settings.photoFor[personId]) || (m && m.file ? m : null); } function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } } @@ -327,7 +334,7 @@ module.exports = { * left alone, so a folder and hand-uploads can coexist. Returns true if anything changed. */ const IMG_RE = /\.(jpe?g|png|webp|gif)$/i; - const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim(); + const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums" function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; } function scanFolder() { if (!photoFolder) return false; @@ -546,8 +553,8 @@ module.exports = { const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName)); if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement const m = who[0]; - rememberPhoto(m.personId, e.personId); - board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId }, plan: e.plan }; + rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId); + board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan }; } // Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that @@ -569,9 +576,10 @@ module.exports = { for (let k = 0; k < openIdx.length; k += 1) { const i = openIdx[k]; const m = assigned[k]; if (m) { - const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot + // The booth's own photos win over the PCO headshot — the one for the instrument they're on. + const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label)); const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl); - board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId } }; + board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } }; } else if (board[String(i)]) { delete board[String(i)]; // synced or stale, and no longer on the plan } @@ -614,6 +622,7 @@ module.exports = { library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots, planId: () => (latest.plan && latest.plan.id) || '', rememberPhoto, + slotLabel: (i) => effectiveSlots()[Number(i)] || '', photoUrlFor, libraryList, setChoir, choirOn: () => choirOn, }; @@ -726,7 +735,8 @@ module.exports = { if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) { // A photo of the person already in this spot: it's their photo now — this week and every // Sync after. They keep their Planning Center name and their spot. - current.rememberPhoto(here.pco.personId, personId); + // Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone. + current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId); current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() }; } else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() }; else delete current.board[slot]; From 6fffefc6c0249a6a1bfbb89c88cec59c778bfad8 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 25 Oct 2026 13:57:45 -0400 Subject: [PATCH 16/23] Overflow Tap: the tile says what Tap is on; the corner dot says how it went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "TAP IS ON · since …" line (and its SWITCHING / NOT SWITCHED forms, and the red error line under the destination) are gone from the tile. A tile shows the keyword Tap is on, its destination, the groups moved, the slide chip and the trail; status is the corner dot's job, as everywhere else in ProdDash. The dot now follows the retries honestly: a switch Overflow refuses is retried three times, ten seconds apart, with the dot amber ("retrying (n of 4 attempts failed)") while the module is still fixing it itself; only when the attempts are spent does the dot go red with the reason on hover, and the alert fire if it is switched on. The budget counts refusals in a row across keywords, not per keyword — a service keeps bringing new keywords, and each one restarting a fresh budget meant a dead Overflow never turned the dot red. The alert is raised once per outage, and a success clears the streak. Verified against the mock with --fail-switch: amber through three refusals, red on the fourth with one alert, later refusals stay red without re-alerting, and green again on the first success once the mock is back. Co-Authored-By: Claude Fable 5.1 --- README.md | 12 ++++++---- modules/overflow-tap/client.js | 41 ++++++++++++--------------------- modules/overflow-tap/server.js | 42 +++++++++++++++++++++++++++++----- modules/overflow-tap/style.css | 33 +++----------------------- 4 files changed, 61 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index af46e81..c65f2dd 100644 --- a/README.md +++ b/README.md @@ -524,11 +524,13 @@ below. Nothing is installed without someone clicking. A slide with no keyword changes nothing. Keywords match as the helper matches them: as a whole word of the notes, any case, punctuation around it ignored (`give` fires on "Give!" and not on "forgiveness"). The tile is - one keyword, big: what Tap is on (and since when), its destination and the - groups moved, a chip saying whether the slide on screen carries a keyword, - and the last four switches with their times; a failed switch turns the - tile red with the reason, is retried, and — if switched on in admin — - flashes an alert channel. Admin config: the ProPresenter endpoint and an + one keyword, big: what Tap is on, its destination and the groups moved, a + chip saying whether the slide on screen carries a keyword, and the last + four switches with their times. How a switch went is the corner dot's + job: a switch Overflow refuses is retried three times with the dot left + alone, and only when those are spent does the dot go red (hover it for + the reason) and, if switched on in admin, an alert channel flash. Admin + config: the ProPresenter endpoint and an **Overflow Dashboard login** (email and password; Google sign-in has no password, so set one with "Forgot Your Password?"). The keywords, their destinations and their Tap groups stay where Overflow keeps them — diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js index dee1221..2532d48 100644 --- a/modules/overflow-tap/client.js +++ b/modules/overflow-tap/client.js @@ -1,10 +1,12 @@ /* Overflow Tap — the tile. * - * One fact matters across a booth: which keyword Tap is on right now, and - * whether the switch actually landed. That is the big line. Under it, the - * destination it maps to and the groups it moved; a small line says whether - * the slide on screen carries a keyword at all; and the last four switches - * sit at the bottom with their times, so a tech can see the service's trail. + * One fact matters across a booth: which keyword Tap is on right now. That + * is the big line. Under it, the destination it maps to and the groups it + * moved; a small line says whether the slide on screen carries a keyword at + * all; and the last four switches sit at the bottom with their times, so a + * tech can see the service's trail. Whether the last switch landed is the + * corner dot's to say — red when Overflow refused it and the module's own + * retries did not fix that — never a line of text in the tile. * * Everything comes from the server's `state` stream. The tile never talks to * ProPresenter or Overflow itself. @@ -74,34 +76,21 @@ export default function create({ root, moduleApi }) { box.append(el('div', 'ot-offline', why)); } + // The keyword Tap is on, its destination and the groups moved. No status + // words here: how the switch went is the corner dot's job (its hover + // carries the reason), and the trail's ✓ / ✕ keeps the record. The + // keyword's colour follows along — dimmed while a switch is in flight, + // red once Overflow has refused it for good. const main = el('div', 'ot-main'); if (cur) { - // The label carries the state and, when there is one, the moment it - // became true; the small lines under the destination are the groups - // moved — or, when the switch failed, what went wrong, in their place - // (a tile has room for one or the other). - let label = `TAP IS ON · since ${clock(cur.at)}`; - let tone = 'ok'; - let note = ''; - if (cur.pending) { - label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; - tone = 'muted'; - } else if (!cur.ok) { - label = `NOT SWITCHED · ${clock(cur.at)}`; - tone = 'alert'; - note = cur.error || ''; - } - main.dataset.tone = tone; - main.append(el('div', 'ot-label', label)); + main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert'); main.append(el('div', 'ot-keyword', cur.keyword)); if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || '')); - if (note) main.append(el('div', 'ot-error', note)); - else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); + if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); } else { main.dataset.tone = 'muted'; - main.append(el('div', 'ot-label', 'NO KEYWORD YET')); main.append(el('div', 'ot-keyword is-none', '—')); - main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it')); + main.append(el('div', 'ot-dest', 'No keyword sent yet')); } box.append(main); diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js index 3036a76..c47544d 100644 --- a/modules/overflow-tap/server.js +++ b/modules/overflow-tap/server.js @@ -11,6 +11,11 @@ * tap a disc follows what is on screen. A slide with no keyword changes * nothing; the last destination stays until the next keyworded slide. * + * A switch Overflow refuses is retried three times, ten seconds apart, with + * the tile's dot left alone meanwhile; only when those are spent does the + * dot go red (and the alert fire, if switched on) — the booth hears about a + * problem the module could not fix itself, not about every hiccup. + * * GET /state → the view-model every tile draws * GET /stream → SSE, `state` events on every change (heartbeated) * POST /refresh → re-read the keyword mappings from the Dashboard now @@ -124,6 +129,14 @@ module.exports = { let retriesLeft = 0; let retryTimer = null; let alertRaised = false; + /** + * Refusals in a row, across keywords. The retry budget is per keyword, but + * a service keeps bringing new keywords: if Overflow refuses every one of + * them, each would start a fresh budget and the dot would never go red. + * So the budget is also spent by refusals as a whole, and a success + * clears it. + */ + let consecutiveFailures = 0; // Presentation layer: the slide /v1/status/slide reports. let lastSlideKey = null; @@ -171,10 +184,15 @@ module.exports = { if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…']; if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)']; - if (state.current && !state.current.pending && !state.current.ok) { - return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; + // A refused switch the module is still retrying on its own is amber — + // being worked on; one it has given up on is red — the booth's problem. + const cur = state.current; + if (cur && !cur.ok && !cur.pending && !cur.retrying) { + return ['error', `Tap not switched to ${cur.keyword} — ${cur.error}`]; + } + if (cur && !cur.ok && (cur.retrying || consecutiveFailures > 0)) { + return ['connecting', `Tap not switched to ${cur.keyword} yet — retrying (${consecutiveFailures} of ${SWITCH_RETRIES + 1} attempts failed): ${cur.error || 'in flight'}`]; } - if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; const n = state.keywords.length; const who = forAdmin && session && session.email ? `${session.email}, ` : ''; return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`]; @@ -381,6 +399,7 @@ module.exports = { at: Date.now(), ok: false, pending: true, + retrying: false, // a retry is scheduled: the module is still trying to fix this itself error: '', attempts: 0, }; @@ -399,22 +418,33 @@ module.exports = { } entry.ok = true; entry.error = ''; + entry.retrying = false; entry.at = Date.now(); + consecutiveFailures = 0; clearAlert(); log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`); } catch (err) { if (stopped) return; entry.ok = false; entry.error = describe(err); - log(`Tap not switched to ${target.keyword}: ${entry.error}`); - if (retriesLeft > 0) { + consecutiveFailures += 1; + if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) { + // Still the module's problem: try again shortly; the dot goes amber. retriesLeft -= 1; + entry.retrying = true; + log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`); retryTimer = later(() => { retryTimer = null; runSwitch(); }, SWITCH_RETRY_MS); + } else { + // Out of retries: now it is the booth's problem — red dot, and the + // alert if the operator asked for one (raised once per outage, not + // again for every further keyword Overflow refuses). + entry.retrying = false; + log(`Tap not switched to ${target.keyword}: ${entry.error} — giving up after ${consecutiveFailures} refusals`); + if (!alertRaised) raiseAlert(target.keyword); } - raiseAlert(target.keyword); } finally { entry.pending = false; inFlight = false; diff --git a/modules/overflow-tap/style.css b/modules/overflow-tap/style.css index 9a43d86..561c115 100644 --- a/modules/overflow-tap/style.css +++ b/modules/overflow-tap/style.css @@ -59,22 +59,9 @@ [data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); } [data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); } -[data-module="overflow-tap"] .ot-label { - color: var(--tone); - font-size: clamp(0.5rem, 9cqh, 0.95rem); - font-weight: 700; - letter-spacing: 0.14em; - line-height: 1.2; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - [data-module="overflow-tap"] .ot-keyword { color: var(--tone); - font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw); + font-size: min(clamp(1rem, 44cqh, 7rem), 18cqw); font-weight: 750; line-height: 1.15; max-width: 100%; @@ -114,18 +101,6 @@ white-space: nowrap; } -[data-module="overflow-tap"] .ot-error { - color: var(--danger); - font-size: clamp(0.5rem, 9cqh, 0.95rem); - line-height: 1.3; - max-width: 100%; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} -[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); } - /* ── this slide ─────────────────────────────────────────────────── */ [data-module="overflow-tap"] .ot-slide { @@ -192,12 +167,10 @@ [data-module="overflow-tap"] .ot-history { display: none; } } @container (max-height: 70px) { - [data-module="overflow-tap"] .ot-groups, - [data-module="overflow-tap"] .ot-error { display: none; } + [data-module="overflow-tap"] .ot-groups { display: none; } } @container (max-height: 46px) { - [data-module="overflow-tap"] .ot-dest, - [data-module="overflow-tap"] .ot-label { display: none; } + [data-module="overflow-tap"] .ot-dest { display: none; } } [data-module="overflow-tap"] .ot-empty { From 7c0579cbf970bf0c3b37e966646c21260c566fc5 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:36 -0400 Subject: [PATCH 17/23] ProPresenter Look 1.0.0: manifest --- modules/propresenter-looks/module.json | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 modules/propresenter-looks/module.json diff --git a/modules/propresenter-looks/module.json b/modules/propresenter-looks/module.json new file mode 100644 index 0000000..f4011d1 --- /dev/null +++ b/modules/propresenter-looks/module.json @@ -0,0 +1,58 @@ +{ + "id": "propresenter-looks", + "name": "ProPresenter Look", + "version": "1.0.0", + "proddash": ">=1.5.0", + "description": "The audience look live in ProPresenter right now", + "permissions": [ + { + "kind": "local-network", + "reason": "Reaching ProPresenter on this network" + } + ], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 2, + "h": 1 + }, + "defaultSize": { + "w": 4, + "h": 2 + }, + "configSchema": { + "propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "default": { + "host": "", + "port": 1025 + }, + "help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled." + }, + "password": { + "type": "password", + "label": "Password", + "default": "", + "help": "Only if ProPresenter's network API asks for one. Leave empty otherwise." + } + }, + "instanceSchema": { + "showHeading": { + "type": "switch", + "label": "Heading", + "default": true + }, + "showLive": { + "type": "switch", + "label": "Live status", + "default": true + }, + "showSince": { + "type": "switch", + "label": "Since", + "default": true + } + } +} From 3bfea6010a60a7693ef7fc9ab3ad4a851d90b772 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:40 -0400 Subject: [PATCH 18/23] ProPresenter Look 1.0.0: server entry --- modules/propresenter-looks/server.js | 242 +++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 modules/propresenter-looks/server.js diff --git a/modules/propresenter-looks/server.js b/modules/propresenter-looks/server.js new file mode 100644 index 0000000..e6ef178 --- /dev/null +++ b/modules/propresenter-looks/server.js @@ -0,0 +1,242 @@ +'use strict'; + +/** + * ProPresenter Look — server part. + * + * Polls ProPresenter's network API for the live audience look and fans it + * out to every tile over SSE. Browsers never talk to ProPresenter directly. + * + * GET /state → { state: } (instant paint for new tiles) + * GET /stream → SSE, `state` events (change-detected, heartbeated) + * + * ProPresenter's routes (ProPresenter 7.9+ / 20+): + * GET /v1/look/current → { id: { uuid, name, index }, screens: [...] } + * GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ] + * The current look carries a UUID of its own, never one from /v1/looks. Its + * `index` points at the saved look it matches, or equals the number of saved + * looks when someone has changed it by hand and it matches none. + */ + +const POLL_MS = 1000; // the look matters the moment it changes +const RETRY_MS = 3000; // while ProPresenter is unreachable +const LOOKS_EVERY = 10; // re-read the saved looks every N polls +const REQUEST_TIMEOUT_MS = 2500; + +/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; + +/** Set by init(), read by the routes — both are rebuilt together on remount. */ +let current = null; + +function emptyView(configured = false) { + return { + configured, + reachable: false, + lastError: '', + look: null, // { name, modified, screens } + changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown + updatedAt: 0, // ms epoch of the last good answer + }; +} + +function errorText(err) { + return String(err?.cause?.code || err?.code || err?.message || err || 'failed'); +} + +/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */ +function lookName(entry) { + return String(entry?.id?.name ?? entry?.name ?? '').trim(); +} + +module.exports = { + init({ config, log }) { + const streams = new Set(); + const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; + const host = String(ep.host || '').trim(); + const port = Number(ep.port) || 1025; + const password = String(config.password || ''); + const configured = Boolean(host && port); + + let view = emptyView(configured); + let lastFrame = ''; + let saved = []; // names of the saved looks, in ProPresenter's order + let savedOk = false; + let tick = 0; + let stopped = false; + let pollTimer = null; + + function url(target) { + const built = new URL(`http://${host}:${port}${target}`); + if (password) built.searchParams.set('password', password); + return built; + } + + async function requestJson(target) { + const response = await fetch(url(target), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + const err = new Error(`ProPresenter ${response.status} ${response.statusText}`); + err.status = response.status; + throw err; + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + function broadcast() { + let frame; + try { + frame = JSON.stringify(view); + } catch { + return; + } + // Polls land every second whether anything changed or not; only real + // changes go out, so idle tiles stay idle. + if (frame === lastFrame) return; + lastFrame = frame; + const payload = `event: state\ndata: ${frame}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + } + + async function refreshSaved() { + try { + const list = await requestJson('/v1/looks'); + saved = Array.isArray(list) ? list.map(lookName) : []; + savedOk = true; + } catch (err) { + // An older ProPresenter without the list still answers look/current; + // the live look's own name is shown then. + if (err.status !== 404) throw err; + saved = []; + savedOk = false; + } + } + + function resolveLook(live) { + const index = Number(live?.id?.index); + const own = lookName(live); + const screens = Array.isArray(live?.screens) ? live.screens.length : 0; + if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) { + return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens }; + } + // Changed by hand, matching no saved look. + const modified = savedOk && Number.isInteger(index) && index >= saved.length; + return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens }; + } + + async function pollTick() { + if (stopped) return; + const next = { ...view }; + try { + tick += 1; + if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved(); + const live = await requestJson('/v1/look/current'); + let look = resolveLook(live); + // A look saved since our last read: refresh the list now rather than + // calling it "Custom" for up to ten seconds. + if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) { + await refreshSaved(); + look = resolveLook(live); + } + const before = view.look; + if (before && (before.name !== look.name || before.modified !== look.modified)) { + next.changedAt = Date.now(); + log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`); + } else if (!before) { + log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`); + } + next.look = look; + next.reachable = true; + next.lastError = ''; + next.updatedAt = Date.now(); + } catch (err) { + if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`); + next.reachable = false; + next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed" + tick = 0; // re-read the saved looks first when it comes back + } + if (stopped) return; + view = next; + broadcast(); + pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS); + } + + if (configured) { + log(`following the live look on ${host}:${port}`); + pollTick(); + } else { + log('no ProPresenter host configured — set one in /admin'); + } + + const heartbeat = setInterval(() => { + for (const res of streams) { + try { + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + current = { streams, getView: () => view }; + + return { + stop() { + stopped = true; + clearTimeout(pollTimer); + clearInterval(heartbeat); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current && current.streams === streams) current = null; + }, + health() { + if (!configured) return { status: 'error', message: 'No ProPresenter host configured' }; + if (view.reachable) { + return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` }; + } + return { + status: view.updatedAt || view.lastError ? 'error' : 'connecting', + message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`, + }; + }, + }; + }, + + routes() { + return { + 'GET /state': (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ state: current ? current.getView() : emptyView() })); + }, + + '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.getView() : emptyView())}\n\n`); + if (!current) return void res.end(); + const { streams } = current; + // A viewer that stops reading (locked tablet, sleeping laptop) must + // not queue frames forever; keepalive probes make the OS notice. + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + streams.add(res); + req.on('close', () => streams.delete(res)); + }, + }; + }, +}; From 09f59c1bef394f7ff082b5986b1b85db1d7c5935 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:53 -0400 Subject: [PATCH 19/23] ProPresenter Look 1.0.0: client entry --- modules/propresenter-looks/client.js | 192 +++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 modules/propresenter-looks/client.js diff --git a/modules/propresenter-looks/client.js b/modules/propresenter-looks/client.js new file mode 100644 index 0000000..785337d --- /dev/null +++ b/modules/propresenter-looks/client.js @@ -0,0 +1,192 @@ +/* ProPresenter Look — client part. + + The server part polls ProPresenter and streams the live audience look + here over SSE; the tile only renders. The look's name fills the tile so + it reads from across the booth, flashes once when it changes, and stays + on screen dimmed — marked "last known" — while ProPresenter is + unreachable. */ + +export default function create({ root, moduleApi }) { + let view = null; + let feedOffline = false; + let stream = null; + let resizeObserver = null; + let flashTimer = null; + let lastName = null; + + root.innerHTML = ` +
+
+
Look
+
Connecting…
+
+
Connecting…
+
+ + +
+
`; + + const screenEl = root.querySelector('.pl-screen'); + const topEl = root.querySelector('.pl-top'); + const headingEl = root.querySelector('.pl-heading'); + const liveEl = root.querySelector('.pl-live'); + const liveTextEl = root.querySelector('.pl-live-text'); + const nameBox = root.querySelector('.pl-name-box'); + const nameEl = root.querySelector('.pl-name'); + const metaEl = root.querySelector('.pl-meta'); + const tagEl = root.querySelector('.pl-tag'); + const sinceEl = root.querySelector('.pl-since'); + + const on = (key) => moduleApi.instanceSettings[key] !== false; + + function timeText(ms) { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + + /** Biggest font size at which the name fits its box without breaking a word. */ + function fitName() { + const MIN = 11; + const boxHeight = nameBox.clientHeight; + const boxWidth = nameBox.clientWidth; + nameEl.style.overflowWrap = 'normal'; + if (boxHeight < MIN || !boxWidth) { + nameEl.style.fontSize = `${MIN}px`; + return; + } + const fits = (size) => { + nameEl.style.fontSize = `${size}px`; + return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1; + }; + let lo = MIN; + let hi = Math.max(MIN, Math.floor(boxHeight)); + let best = MIN; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; } + } + nameEl.style.fontSize = `${best}px`; + // One word too long even at MIN: let it break rather than overflow. + if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere'; + } + + function flash() { + screenEl.classList.remove('is-changed'); + void screenEl.offsetWidth; // restart the animation + screenEl.classList.add('is-changed'); + clearTimeout(flashTimer); + flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600); + } + + function setStatus() { + if (feedOffline) { + moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); + } else if (!view) { + moduleApi.setStatus('connecting', 'Connecting…'); + } else if (!view.configured) { + moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + } else if (view.reachable) { + moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`); + } else if (!view.updatedAt && !view.lastError) { + moduleApi.setStatus('connecting', 'Reaching ProPresenter…'); + } else { + moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`); + } + } + + function render() { + setStatus(); + const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError)); + const look = view && view.look; + + screenEl.classList.toggle('is-offline', offline); + headingEl.hidden = !on('showHeading'); + liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows + topEl.hidden = headingEl.hidden && liveEl.hidden; + + liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable)); + liveEl.classList.toggle('is-down', offline); + if (feedOffline) liveTextEl.textContent = 'ProdDash offline'; + else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable'; + else if (view && view.reachable) liveTextEl.textContent = 'Live'; + else liveTextEl.textContent = 'Connecting…'; + + let name; + let waiting = false; + if (look) { + name = look.name; + } else if (view && !view.configured) { + name = 'Set up in Admin'; + waiting = true; + } else if (offline) { + name = 'ProPresenter unreachable'; + waiting = true; + } else { + name = 'Connecting…'; + waiting = true; + } + nameEl.textContent = name; + nameEl.classList.toggle('is-waiting', waiting); + + if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash(); + if (look) lastName = `${look.name}|${look.modified}`; + + tagEl.hidden = !(look && look.modified); + const since = look && view.changedAt && on('showSince'); + sinceEl.hidden = !since; + if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`; + metaEl.hidden = tagEl.hidden && sinceEl.hidden; + + fitName(); + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { + feedOffline = false; + render(); + }, + error() { + feedOffline = true; + render(); + }, + events: { + state(e) { + try { + view = JSON.parse(e.data); + } catch { + return; + } + feedOffline = false; + render(); + }, + }, + }); + } + + return { + start() { + render(); + moduleApi.fetch('/state') + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + if (body && body.state && !view) { + view = body.state; + render(); + } + }) + .catch(() => {}); + connect(); + resizeObserver = new ResizeObserver(() => fitName()); + resizeObserver.observe(nameBox); + }, + stop() { + if (stream) stream.close(); + stream = null; + if (resizeObserver) resizeObserver.disconnect(); + resizeObserver = null; + clearTimeout(flashTimer); + root.innerHTML = ''; + }, + }; +} From cd13ecaf7c7ad729ec42bd4589005195a7a7077b Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:12 -0400 Subject: [PATCH 20/23] ProPresenter Look 1.0.0: stylesheet --- modules/propresenter-looks/style.css | 141 +++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 modules/propresenter-looks/style.css diff --git a/modules/propresenter-looks/style.css b/modules/propresenter-looks/style.css new file mode 100644 index 0000000..4a5bb8a --- /dev/null +++ b/modules/propresenter-looks/style.css @@ -0,0 +1,141 @@ +/* ProPresenter Look — the live look's name, as big as the tile allows. + Colors come from the shell's variables only. */ + +[data-module="propresenter-looks"] .pl-screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + user-select: none; + -webkit-user-select: none; + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--accent) 12%, transparent), + color-mix(in srgb, var(--bg) 60%, transparent) + ); +} + +/* ── top row: heading and live status ── */ + +[data-module="propresenter-looks"] .pl-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-shrink: 0; + min-height: 0; +} +[data-module="propresenter-looks"] .pl-top[hidden], +[data-module="propresenter-looks"] .pl-heading[hidden], +[data-module="propresenter-looks"] .pl-live[hidden], +[data-module="propresenter-looks"] .pl-meta[hidden], +[data-module="propresenter-looks"] .pl-tag[hidden], +[data-module="propresenter-looks"] .pl-since[hidden] { + display: none; +} + +[data-module="propresenter-looks"] .pl-heading { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +[data-module="propresenter-looks"] .pl-live { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +[data-module="propresenter-looks"] .pl-live-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--border); + transition: background 0.3s ease; +} +[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot { + background: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); +} +[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot { + background: var(--danger); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent); +} +[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text { + color: var(--danger); +} + +/* ── the look's name ── */ + +[data-module="propresenter-looks"] .pl-name-box { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +[data-module="propresenter-looks"] .pl-name { + max-width: 100%; + font-size: 11px; + font-weight: 800; + line-height: 1.05; + text-align: center; + white-space: normal; + word-break: normal; + hyphens: none; + color: var(--text); +} +[data-module="propresenter-looks"] .pl-name.is-waiting { + font-weight: 600; + color: var(--muted); +} + +/* Unreachable: the last known look stays, visibly stale. */ +[data-module="propresenter-looks"] .pl-screen.is-offline { + background: color-mix(in srgb, var(--danger) 8%, transparent); +} +[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) { + opacity: 0.45; +} + +/* ── footer: modified tag and when it changed ── */ + +[data-module="propresenter-looks"] .pl-meta { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-shrink: 0; + font-size: 11px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +[data-module="propresenter-looks"] .pl-tag { + padding: 1px 7px; + border-radius: 999px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 10px; + color: var(--warn); + background: color-mix(in srgb, var(--warn) 16%, transparent); +} + +/* A new look: one bright pulse so a glance catches the change. */ +[data-module="propresenter-looks"] .pl-screen.is-changed { + animation: pl-changed 2.6s ease-out; +} +@keyframes pl-changed { + 0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); } + 100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; } +} From 70f56c7909c5a3155397fa09652d917f01645816 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:25 -0400 Subject: [PATCH 21/23] ProPresenter Look 1.0.0: README --- modules/propresenter-looks/README.md | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 modules/propresenter-looks/README.md diff --git a/modules/propresenter-looks/README.md b/modules/propresenter-looks/README.md new file mode 100644 index 0000000..cff2841 --- /dev/null +++ b/modules/propresenter-looks/README.md @@ -0,0 +1,55 @@ +# ProPresenter Look — a ProdDash module + +Shows the audience **look** live in ProPresenter right now, as big as the +tile allows, so anyone away from the ProPresenter machine can tell at a glance +which look the outputs are on. + +- The look's name fills the tile and **flashes** when it changes. +- **Modified** appears when someone changed the look by hand and it no longer + matches any saved look. +- **since 10:42 AM** says when the look last changed. +- If ProPresenter stops answering, the status dot goes red and the tile keeps + the **last known** look on screen, dimmed, until it comes back by itself. + +## Install + +This folder is a standard ProdDash module (id `propresenter-looks`). Per the +ProdDash module guide, it installs one of two ways: + +1. **From the admin page's catalog**, once the folder is in the ProdDash repo + at `modules/propresenter-looks/`: `/admin` → Available modules → + *ProPresenter Look* → Install. +2. **Drop it in**: copy this folder to the ProdDash data directory's + `modules/propresenter-looks/` and restart ProdDash. + - macOS: `~/Library/Application Support/ProdDash/modules/` + - Windows: `%APPDATA%\ProdDash\modules\` + +Then add a tile from **+ Add tile → ProPresenter Look**. + +## Configure (in `/admin`) + +| Setting | What to enter | +| --- | --- | +| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). | +| Password | Only if ProPresenter's network API asks for one. | + +Each tile's gear has three switches: **Heading**, **Live status**, **Since**. + +## How it works + +The server entry polls ProPresenter's network API once a second +(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the +saved look it matches) and streams the result to tiles over SSE. Browsers +never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and +ProPresenter 7.9+ (or 20+). + +## Testing without ProPresenter + +```bash +node tools/pp-looks-mock.js 1601 +``` + +Point the module at host `127.0.0.1`, port `1601`. The live look changes every +8 seconds (`--every 3` for faster) and every fourth change is a hand-modified +look. `--password ` demands a password; `--no-list` acts like a +ProPresenter without `GET /v1/looks`. From 49f8a82fbd39bbced3bb478aefd7c20ff26403ca Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:58:39 -0400 Subject: [PATCH 22/23] ProPresenter Look 1.0.0: mock ProPresenter looks API --- .../propresenter-looks/tools/pp-looks-mock.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 modules/propresenter-looks/tools/pp-looks-mock.js diff --git a/modules/propresenter-looks/tools/pp-looks-mock.js b/modules/propresenter-looks/tools/pp-looks-mock.js new file mode 100644 index 0000000..a268f03 --- /dev/null +++ b/modules/propresenter-looks/tools/pp-looks-mock.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Mock ProPresenter looks API, for developing the ProPresenter Look module + * without the real thing. + * + * node tools/pp-looks-mock.js [port] (default 1601) + * --every how often the live look changes (default 8) + * --password demand ?password= like a locked ProPresenter + * --no-list answer 404 for /v1/looks (an older ProPresenter) + * + * Point the module at host 127.0.0.1 and this port. Every few changes the + * live look is "modified by hand", so it matches no saved look. + */ + +const http = require('http'); +const crypto = require('crypto'); + +const args = process.argv.slice(2); +const flag = (name) => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +}; +const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601; +const every = Number(flag('--every')) || 8; +const password = flag('--password') || ''; +const noList = args.includes('--no-list'); + +const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null }); +const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({ + id: { uuid: crypto.randomUUID(), name, index }, + screens: [screen(true), screen(index !== 4)], +})); + +let live = 0; +let modified = false; +let changes = 0; +const liveUuid = crypto.randomUUID(); + +setInterval(() => { + changes += 1; + modified = changes % 4 === 0; + if (!modified) live = (live + 1) % saved.length; + console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`); +}, every * 1000); + +function currentLook() { + return { + id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live }, + screens: saved[live].screens, + }; +} + +http.createServer((req, res) => { + const url = new URL(req.url, 'http://x'); + const send = (code, body) => { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(body === undefined ? '' : JSON.stringify(body)); + }; + if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' }); + if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' }); + if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved); + if (url.pathname === '/v1/look/current') return send(200, currentLook()); + send(404); +}).listen(port, () => { + console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`); + console.log(`live look → ${saved[live].id.name}`); +}); From 7c4217a23a9300ba21359daaf83130e90026a785 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 27 Oct 2026 13:08:44 -0400 Subject: [PATCH 23/23] ProPresenter 1.2.0: the live pill shows the audience look The Now / Next and Now tiles' header pill now carries the name of the Audience Look live in ProPresenter where it used to say "Live". The module reads /v1/look/current once a second (and the saved list every tenth poll) with the feed's own endpoint and password, and sends the look as an additive `look` field in its stream and snapshot. A single slow or failed read keeps the last name; the feed's own reachable state is never touched. With no look to show the pill falls back to "Live". The gear switch is relabelled "Audience look" (same key, so saved layouts keep working). Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- modules/propresenter-now-next/client.js | 15 +- modules/propresenter-now-next/module.json | 4 +- modules/propresenter-now-next/server.js | 187 +++++++++++++++++++--- modules/propresenter-now-next/style.css | 18 +++ 5 files changed, 200 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 56bcd19..8e05ecf 100644 --- a/README.md +++ b/README.md @@ -366,8 +366,8 @@ below. Nothing is installed without someone clicking. the live item alone with the gradient filling the tile edge to edge — a wall panel that says one thing. Admin config: ProPresenter endpoint (host : port), password. - Tile settings: a **Show** group of switches for the playlist name, live - status, NOW and NEXT labels, slide count, progress bar, the next list and + Tile settings: a **Show** group of switches for the playlist name, audience + look, NOW and NEXT labels, slide count, progress bar, the next list and its item tags, plus tidy titles. - **Timers** — live ProPresenter timers (countdown, countdown-to-time, elapsed), the **video countdown** (time left on what ProPresenter is diff --git a/modules/propresenter-now-next/client.js b/modules/propresenter-now-next/client.js index f168a88..ab593e8 100644 --- a/modules/propresenter-now-next/client.js +++ b/modules/propresenter-now-next/client.js @@ -212,7 +212,8 @@ export default function create({ root, moduleApi }) { } function renderLive() { - liveEl.classList.remove('is-live', 'is-down'); + liveEl.classList.remove('is-live', 'is-down', 'is-look', 'is-modified'); + liveEl.removeAttribute('title'); if (feedOffline) { liveEl.classList.add('is-down'); liveTextEl.textContent = 'Feed offline'; @@ -231,6 +232,18 @@ export default function create({ root, moduleApi }) { } if (state.reachable) { liveEl.classList.add('is-live'); + const lookName = state.look && state.look.name; + if (lookName) { + // The audience look replaces "Live". textContent, never innerHTML: + // the name is whatever someone typed into ProPresenter. + const modified = Boolean(state.look.modified); + liveEl.classList.add('is-look'); + liveEl.classList.toggle('is-modified', modified); + liveEl.title = modified ? 'Audience look (modified)' : 'Audience look'; + liveTextEl.textContent = lookName; + moduleApi.setStatus('ok', `Live · ${lookName}`); + return; + } liveTextEl.textContent = 'Live'; moduleApi.setStatus('ok', 'Following ProPresenter'); return; diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index 2b02e79..8a5f92c 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-now-next", "name": "ProPresenter", - "version": "1.1.1", + "version": "1.2.0", "proddash": ">=1.5.0", "description": "What is live in ProPresenter and what comes next", "permissions": [ @@ -50,7 +50,7 @@ }, "showLive": { "type": "switch", - "label": "Live status", + "label": "Audience look", "default": true, "group": "Show" }, diff --git a/modules/propresenter-now-next/server.js b/modules/propresenter-now-next/server.js index bf7048a..31ccee7 100644 --- a/modules/propresenter-now-next/server.js +++ b/modules/propresenter-now-next/server.js @@ -10,6 +10,12 @@ * * GET /state → { state: } (instant paint for new tiles) * GET /stream → SSE, `state` events (change-detected, heartbeated) + * + * The view-model carries `look`: the live audience look as + * { name, modified }, or null while it is unknown. A small poller of this + * file's own reads it (GET /v1/look/current, with /v1/looks to name it) from + * the same ProPresenter the core follows. Its failures only null `look`; they + * never touch the core's reachable / lastError. */ const { createProPresenterClient, clearedViewModel } = require('./propresenter-core'); @@ -17,6 +23,17 @@ const { createProPresenterClient, clearedViewModel } = require('./propresenter-c /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ const HEARTBEAT_MS = 15000; +const LOOK_POLL_MS = 1000; // the look matters the moment it changes +const LOOK_RETRY_MS = 3000; // while the look cannot be read +const LOOKS_EVERY = 10; // re-read the saved looks every N polls +const LOOK_TIMEOUT_MS = 2500; +const LOOK_FAILS_BEFORE_NULL = 3; // consecutive failed reads before the look is called unknown + +/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */ +function lookName(entry) { + return String(entry?.id?.name ?? entry?.name ?? '').trim(); +} + /** Set by init(), read by the routes — both are rebuilt together on remount. */ let current = null; @@ -31,30 +48,38 @@ module.exports = { const port = Number(config.port ?? ep.port) || 1025; const enabled = Boolean(host && port); let latest = clearedViewModel(); + let look = null; // { name, modified } or null while unknown let lastFrame = ''; + /** The core's view-model plus the live look — what every tile and GET /state sees. */ + const snapshot = () => ({ ...latest, look }); + + function broadcast() { + if (!streams.size) return; + let frame; + try { + frame = JSON.stringify(snapshot()); + } catch { + return; + } + // The poll loop emits every tick whether anything changed or not. + // Broadcasting only real changes keeps idle tiles genuinely idle. + if (frame === lastFrame) return; + lastFrame = frame; + const payload = `event: state\ndata: ${frame}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + } + const client = createProPresenterClient({ onData(view) { latest = view; - if (!streams.size) return; - let frame; - try { - frame = JSON.stringify(view); - } catch { - return; - } - // The poll loop emits every tick whether anything changed or not. - // Broadcasting only real changes keeps idle tiles genuinely idle. - if (frame === lastFrame) return; - lastFrame = frame; - const payload = `event: state\ndata: ${frame}\n\n`; - for (const res of streams) { - try { - res.write(payload); - } catch { - streams.delete(res); - } - } + broadcast(); }, }); @@ -65,8 +90,122 @@ module.exports = { password: String(config.password || ''), }); + /* ── audience look ────────────────────────────────────────────── + Same reads and rules as the ProPresenter Look module. ProPresenter + sometimes leaves the live look's name blank, so the saved list is + consulted: the look's `index` points at the saved look it matches, or + is past the end when someone has changed it by hand. */ + + const password = String(config.password || ''); + let saved = []; // names of the saved looks, in ProPresenter's order + let savedOk = false; // the list exists and is in `saved` + let savedTried = false; // /v1/looks has answered (a 404 counts: do not ask again every poll) + let lookTick = 0; + let lookFails = 0; // consecutive failed reads of the look + let lookStopped = false; + let lookTimer = null; + + function lookUrl(target) { + const built = new URL(`http://${host}:${port}${target}`); + if (password) built.searchParams.set('password', password); + return built; + } + + async function lookJson(target) { + const response = await fetch(lookUrl(target), { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(LOOK_TIMEOUT_MS), + }); + if (!response.ok) { + const err = new Error(`ProPresenter ${response.status} ${response.statusText}`); + err.status = response.status; + throw err; + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async function refreshSaved() { + try { + const list = await lookJson('/v1/looks'); + // A real empty array is a valid (empty) list. Anything else that + // answered 200 is not a list, so it names nothing — an empty list + // would call every look modified. + saved = Array.isArray(list) ? list.map(lookName) : []; + savedOk = Array.isArray(list); + savedTried = true; + } catch (err) { + // An older ProPresenter without the list still answers look/current; + // the live look's own name is shown then. Remembered, so the list is + // asked for again only on the normal cadence, not on every poll. + if (err.status !== 404) throw err; + saved = []; + savedOk = false; + savedTried = true; + } + } + + /** { name, modified }, or null when there is no name to show (the pill then says "Live"). */ + function resolveLook(live) { + const index = Number(live?.id?.index); + const own = lookName(live); + if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) { + return { name: saved[index] || own || `Look ${index + 1}`, modified: false }; + } + // Changed by hand, matching no saved look. + if (savedOk && Number.isInteger(index) && index >= saved.length) { + return { name: own || 'Custom look', modified: true }; + } + // No saved list to name it from: the live look's own name, or nothing. + return own ? { name: own, modified: false } : null; + } + + function setLook(next) { + if (JSON.stringify(next) === JSON.stringify(look)) return; + look = next; + broadcast(); + } + + async function pollLook() { + if (lookStopped) return; + let next = null; + let readOk = false; + try { + lookTick += 1; + if (lookTick % LOOKS_EVERY === 1 || !savedTried) await refreshSaved(); + const live = await lookJson('/v1/look/current'); + if (!live || typeof live !== 'object') throw new Error('no look in the answer'); + next = resolveLook(live); + // A look saved since our last read: refresh the list now rather than + // calling it "Custom" for up to ten seconds. + if (next?.modified && !look?.modified && lookTick % LOOKS_EVERY !== 1) { + await refreshSaved(); + next = resolveLook(live); + } + readOk = true; + } catch { + // A list that exists may have changed while ProPresenter was away: + // re-read it first when it comes back. One that is known not to exist + // (404) is not asked for again on every failed retry. + if (savedOk) lookTick = 0; + } + if (lookStopped) return; + if (readOk) { + lookFails = 0; + setLook(next); + } else { + // One slow or dropped read must not blank the pill: the last good look + // stands until the reads have failed several times running. A real + // outage shows as "Reconnecting…" through the core, not as this name. + lookFails += 1; + if (lookFails >= LOOK_FAILS_BEFORE_NULL) setLook(null); + } + lookTimer = setTimeout(pollLook, readOk ? LOOK_POLL_MS : LOOK_RETRY_MS); + } + if (enabled) { client.start(); + pollLook(); log(`following ProPresenter at ${host}:${port}`); } else { log('no ProPresenter host configured — set one in /admin'); @@ -83,10 +222,12 @@ module.exports = { }, HEARTBEAT_MS); heartbeat.unref?.(); - current = { streams, getLatest: () => latest }; + current = { streams, getLatest: snapshot }; return { stop() { + lookStopped = true; + clearTimeout(lookTimer); clearInterval(heartbeat); client.stop(); for (const res of streams) { @@ -135,7 +276,7 @@ module.exports = { instanceSchema: { cleanTitles: { type: 'switch', label: 'Tidy titles', default: true }, showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' }, - showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' }, + showLive: { type: 'switch', label: 'Audience look', default: true, group: 'Show' }, showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' }, showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' }, showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' }, @@ -149,7 +290,7 @@ module.exports = { return { 'GET /state': (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ state: current ? current.getLatest() : clearedViewModel() })); + res.end(JSON.stringify({ state: current ? current.getLatest() : { ...clearedViewModel(), look: null } })); }, 'GET /stream': (req, res) => { @@ -160,7 +301,7 @@ module.exports = { 'X-Accel-Buffering': 'no', }); res.write('retry: 3000\n\n'); - const view = current ? current.getLatest() : clearedViewModel(); + const view = current ? current.getLatest() : { ...clearedViewModel(), look: null }; res.write(`event: state\ndata: ${JSON.stringify(view)}\n\n`); if (!current) return void res.end(); const { streams } = current; diff --git a/modules/propresenter-now-next/style.css b/modules/propresenter-now-next/style.css index 3962d09..2acee48 100644 --- a/modules/propresenter-now-next/style.css +++ b/modules/propresenter-now-next/style.css @@ -141,6 +141,24 @@ transition: opacity 0.6s ease 1.4s; } +/* The audience look is information, not a status chip: it stays, in the + name as typed, in the normal text colour. A look changed by hand (matching + no saved look) is set in italic. */ +[data-module="propresenter-now-next"] .nn-live.is-live.is-look .nn-live-text { + opacity: 1; + transition: none; + max-width: 14em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-transform: none; + color: var(--text); + font-weight: 600; +} +[data-module="propresenter-now-next"] .nn-live.is-look.is-modified .nn-live-text { + font-style: italic; +} + /* ── NOW card ── FIXED height so the title box is deterministic and the NOW/NEXT layout cannot oscillate (see the wall display's notes). */