Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc19ef696 | ||
|
|
86a3270f1c | ||
|
|
b3b7b29007 | ||
|
|
62c34fe80c | ||
|
|
4c6a2b8f76 | ||
|
|
b0defbd56b | ||
|
|
914ff7795b | ||
|
|
076e583daf | ||
|
|
21519dd3da | ||
|
|
8843339f11 | ||
|
|
4ed22b361b | ||
|
|
aec9c47ed4 | ||
|
|
5f60cd563c | ||
|
|
de32046553 | ||
|
|
2f4c357bf5 |
@@ -303,9 +303,11 @@ below. Nothing is installed without someone clicking.
|
||||
status, 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) and LTC timecode as auto-sized cards. The + picker lists every
|
||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||
stay hidden) and LTC timecode as auto-sized cards. The + picker lists
|
||||
every discovered timer (and the LTC card) as its own tile, or add "All
|
||||
timers" and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||
@@ -346,6 +348,15 @@ below. Nothing is installed without someone clicking.
|
||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||
show/hide checklist for every detail.
|
||||
|
||||
With an **AbleSet** module installed and configured, the Plan tile also
|
||||
follows the band: while AbleSet plays a song that is the item on screen,
|
||||
that item's background fills as the song goes, and its sections slide past
|
||||
in a strip at the bottom right of the item, the one playing filling in
|
||||
turn. Both are switches in the tile's gear (**Song progress**, **Song
|
||||
sections**), and both are simply absent when there is no AbleSet module, it
|
||||
is switched off, or it has no host — nothing to turn off and nothing to
|
||||
explain.
|
||||
|
||||
**Teams** shows the same plan's team and position assignments — each team
|
||||
as a band of its people, with their photo (fetched through ProdDash, never
|
||||
by the browser; initials when Planning Center has none) and a dot for
|
||||
|
||||
@@ -684,6 +684,7 @@ clock, and any module that offers some. A module offers timers by:
|
||||
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||||
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||||
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||||
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
|
||||
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||||
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||||
| `detail` | Optional second line. |
|
||||
|
||||
+142
-1
@@ -34,6 +34,7 @@ function createPlan({ root, moduleApi }) {
|
||||
let ticker = null;
|
||||
let lastCurrentId = null;
|
||||
let lastListKey = '';
|
||||
let absView = null; // what the server says AbleSet is doing, if anything
|
||||
let userScrolledAt = 0;
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -493,6 +494,133 @@ function createPlan({ root, moduleApi }) {
|
||||
renderLiveMarks();
|
||||
}
|
||||
|
||||
/* ── AbleSet: the song playing under the current item ──────────────
|
||||
Everything here draws only when the server says there is an AbleSet
|
||||
module, it is configured and reachable, and the song it is playing is
|
||||
the plan item on screen. Any of those missing and the tile looks
|
||||
exactly as it did before AbleSet existed. */
|
||||
|
||||
/** The playhead now, in beats, carried forward from the last packet. */
|
||||
function absBeatsNow() {
|
||||
if (!absView || absView.beats == null) return null;
|
||||
if (!absView.playing || !absView.tempo) return absView.beats;
|
||||
const since = Math.max(0, serverNow() - absView.at) / 1000;
|
||||
return absView.beats + since * (absView.tempo / 60);
|
||||
}
|
||||
|
||||
function absFraction(part) {
|
||||
const beats = absBeatsNow();
|
||||
if (beats == null || !part || part.start == null || part.end == null) return null;
|
||||
const span = part.end - part.start;
|
||||
if (!(span > 0)) return null;
|
||||
return Math.max(0, Math.min(1, (beats - part.start) / span));
|
||||
}
|
||||
|
||||
/** The row AbleSet's song belongs to — only ever the current one. */
|
||||
function absRow() {
|
||||
const a = absView;
|
||||
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
||||
if (a.itemId !== lastCurrentId) return null;
|
||||
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
||||
}
|
||||
|
||||
/** Keep the playing section visible in the strip — this strip, nothing else. */
|
||||
function absScrollSections(strip) {
|
||||
const active = strip.querySelector('.pp-abs-sec.is-now');
|
||||
if (!active) return;
|
||||
const box = strip.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function renderAbleSet() {
|
||||
const row = absRow();
|
||||
|
||||
// Anything left on a row that is no longer the one goes.
|
||||
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
|
||||
if (!row || !row.contains(stale)) stale.remove();
|
||||
}
|
||||
if (!row) return;
|
||||
|
||||
if (on('absProgress')) {
|
||||
const fraction = absFraction(absView.song);
|
||||
if (fraction == null) {
|
||||
row.querySelector('.pp-fill')?.remove();
|
||||
} else {
|
||||
let fill = row.querySelector('.pp-fill');
|
||||
if (!fill) {
|
||||
fill = document.createElement('div');
|
||||
fill.className = 'pp-fill';
|
||||
row.prepend(fill); // behind the row's content, which the stylesheet lifts
|
||||
}
|
||||
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
|
||||
}
|
||||
} else {
|
||||
row.querySelector('.pp-fill')?.remove();
|
||||
}
|
||||
|
||||
if (on('absSections') && absView.sections.length) {
|
||||
const main = row.querySelector('.pp-col-main');
|
||||
let strip = row.querySelector('.pp-abs-sections');
|
||||
if (!strip && main) {
|
||||
strip = document.createElement('div');
|
||||
strip.className = 'pp-abs-sections';
|
||||
main.appendChild(strip);
|
||||
}
|
||||
if (strip) {
|
||||
// The chips are rebuilt only when the song's sections change. Moving
|
||||
// on to the next section just moves the classes: rebuilding would
|
||||
// throw the strip's scroll away thirteen times a song.
|
||||
const key = absView.sections.map((s) => s.name).join('\u0000');
|
||||
if (strip.dataset.key !== key) {
|
||||
strip.dataset.key = key;
|
||||
strip.dataset.now = '';
|
||||
strip.innerHTML = '';
|
||||
for (const section of absView.sections) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'pp-abs-sec';
|
||||
chip.textContent = section.name;
|
||||
strip.appendChild(chip);
|
||||
}
|
||||
}
|
||||
const now = absView.section?.index ?? -1;
|
||||
if (strip.dataset.now !== String(now)) {
|
||||
strip.dataset.now = String(now);
|
||||
[...strip.children].forEach((chip, i) => {
|
||||
chip.classList.toggle('is-now', i === now);
|
||||
chip.classList.toggle('is-done', i < now);
|
||||
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
|
||||
});
|
||||
requestAnimationFrame(() => absScrollSections(strip));
|
||||
}
|
||||
const active = strip.children[now];
|
||||
if (active) {
|
||||
const fraction = absFraction(absView.section);
|
||||
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
row.querySelector('.pp-abs-sections')?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
|
||||
* scroll every ancestor that can scroll with it, which on a dashboard taller
|
||||
* than the window drags the whole board; moving `listEl.scrollTop` moves
|
||||
* this tile and nothing else. Measured from rectangles rather than
|
||||
* `offsetTop` because the rows' offset parent is the tile, not the list.
|
||||
*/
|
||||
function keepInView(row) {
|
||||
const list = listEl.getBoundingClientRect();
|
||||
const rect = row.getBoundingClientRect();
|
||||
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||
function renderLiveMarks() {
|
||||
const live = state?.live || {};
|
||||
@@ -531,11 +659,12 @@ function createPlan({ root, moduleApi }) {
|
||||
}
|
||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||
}
|
||||
renderAbleSet();
|
||||
if (currentId !== lastCurrentId) {
|
||||
lastCurrentId = currentId;
|
||||
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
if (row) keepInView(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,6 +739,7 @@ function createPlan({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function tick() {
|
||||
renderAbleSet();
|
||||
if (!state?.plan) return;
|
||||
renderClock();
|
||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||
@@ -621,6 +751,7 @@ function createPlan({ root, moduleApi }) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||
if (payload.ableset) absView = payload.ableset;
|
||||
feedOffline = false;
|
||||
render();
|
||||
}
|
||||
@@ -641,6 +772,16 @@ function createPlan({ root, moduleApi }) {
|
||||
accept(JSON.parse(ev.data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
},
|
||||
// Its own event, several a second while a song plays: redraw that one
|
||||
// row rather than the whole list.
|
||||
ableset(ev) {
|
||||
try {
|
||||
absView = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (state) renderAbleSet();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Order of service and team assignments from Planning Center Services",
|
||||
"provides": [
|
||||
@@ -382,6 +382,18 @@
|
||||
"label": "Match line",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"absProgress": {
|
||||
"type": "boolean",
|
||||
"label": "Song progress",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
"absSections": {
|
||||
"type": "boolean",
|
||||
"label": "Song sections",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
}
|
||||
},
|
||||
"tiles": [
|
||||
|
||||
+221
-2
@@ -9,6 +9,10 @@
|
||||
*
|
||||
* GET /state → { state, now } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* plus `ableset` events: the song AbleSet is playing, its
|
||||
* sections and playhead, when there is an AbleSet module
|
||||
* to follow (see below). Its own event so the playhead
|
||||
* never drags the plan onto the wire with it.
|
||||
* POST /refresh → re-read the plan now
|
||||
* GET /service-types → { options } for the admin "Service type" select,
|
||||
* grouped by Planning Center folder path
|
||||
@@ -33,6 +37,13 @@
|
||||
* GET /service_types/{st}/item_note_categories (note category names)
|
||||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
||||
*
|
||||
* Following AbleSet: the same loopback trick, entirely optional. When this
|
||||
* ProdDash has an AbleSet module that is configured and answering, its song,
|
||||
* sections and playhead are passed to the tiles, and the song is matched to a
|
||||
* plan item by name so a tile can fill that item as the song plays. With no
|
||||
* such module — not installed, disabled, no host — tiles are told
|
||||
* `available: false` and draw none of it.
|
||||
*
|
||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||
* this module reads the propresenter-now-next module's own SSE feed over
|
||||
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
||||
@@ -59,7 +70,7 @@ const http = require('http');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||
const {
|
||||
MINUTE,
|
||||
serviceTimes,
|
||||
@@ -85,6 +96,12 @@ const HEARTBEAT_MS = 15000;
|
||||
/** The Now/Next module whose feed we follow. */
|
||||
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||
const NOW_NEXT_RETRY_MS = 3000;
|
||||
/** The AbleSet module, followed the same way — entirely optional (see below). */
|
||||
const ABLESET_ID = 'ableset';
|
||||
const ABLESET_RETRY_MS = 3000;
|
||||
const ABLESET_MISSING_RETRY_MS = 15000;
|
||||
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
|
||||
const ABLESET_POS_MS = 200;
|
||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||
const SEEN_DOCUMENTS_MAX = 200;
|
||||
@@ -153,6 +170,28 @@ function clearedLive() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What tiles are told about AbleSet. `available` false covers every way there
|
||||
* may be nothing to draw — no module installed, disabled, or not answering —
|
||||
* and the tiles then show none of it at all.
|
||||
*/
|
||||
function clearedAbleSet() {
|
||||
return {
|
||||
available: false,
|
||||
configured: false,
|
||||
reachable: false,
|
||||
reason: '',
|
||||
playing: false,
|
||||
tempo: null,
|
||||
song: null,
|
||||
section: null,
|
||||
sections: [],
|
||||
beats: null,
|
||||
itemId: '',
|
||||
at: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The service that owns the clock, as tiles and the timers see it. */
|
||||
function clearedService() {
|
||||
return {
|
||||
@@ -1258,6 +1297,170 @@ module.exports = {
|
||||
const badPorts = new Set();
|
||||
|
||||
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
|
||||
|
||||
/* — AbleSet, over loopback like the ProPresenter feed above — */
|
||||
|
||||
/**
|
||||
* The AbleSet module, if this ProdDash has one. Nothing here is required:
|
||||
* a dashboard with no AbleSet module, a disabled one, or one with no host
|
||||
* configured simply reports `available: false` and the tiles draw none of
|
||||
* it. Installing AbleSet later lights it up without a restart, because a
|
||||
* 404 is retried on the slow timer like any other missing feed.
|
||||
*
|
||||
* What tiles get is deliberately small — the song, its sections, and the
|
||||
* playhead in beats with a stamp — because it moves five times a second
|
||||
* and must never drag the plan (items, teams) onto the wire with it. It
|
||||
* rides its own `ableset` event on the module's one stream.
|
||||
*/
|
||||
let absRequest = null;
|
||||
let absRetryTimer = null;
|
||||
let absPosTimer = null;
|
||||
let absLastSent = 0;
|
||||
let absLastStill = '';
|
||||
let abs = clearedAbleSet();
|
||||
|
||||
function absPart(p) {
|
||||
if (!p || typeof p !== 'object') return null;
|
||||
const name = String(p.name || '').trim();
|
||||
if (!name) return null;
|
||||
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
|
||||
return { name, index: n(p.index), start: n(p.start), end: n(p.end) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plan item AbleSet's song is, by the same name matching the live
|
||||
* ProPresenter document uses. Without this a progress bar would happily
|
||||
* fill the sermon while the band rehearses the next song: the tile only
|
||||
* draws progress when the song AbleSet is playing IS the item on screen.
|
||||
*/
|
||||
function absItemFor(songName) {
|
||||
if (!songName) return '';
|
||||
let best = null;
|
||||
for (const item of state.items) {
|
||||
if (item.itemType === 'header') continue;
|
||||
const score = Math.max(
|
||||
similarity(songName, item.title),
|
||||
item.song ? similarity(songName, item.song.title) : 0
|
||||
);
|
||||
if (score >= MATCH_THRESHOLD && (!best || score > best.score)) best = { id: item.id, score };
|
||||
}
|
||||
return best ? best.id : '';
|
||||
}
|
||||
|
||||
function applyAbleSet(view) {
|
||||
const song = absPart(view?.song);
|
||||
abs = {
|
||||
available: true,
|
||||
configured: Boolean(view?.configured),
|
||||
reachable: Boolean(view?.reachable),
|
||||
playing: Boolean(view?.playing),
|
||||
tempo: Number.isFinite(Number(view?.tempo)) ? Number(view.tempo) : null,
|
||||
song,
|
||||
section: absPart(view?.section),
|
||||
sections: Array.isArray(view?.sections)
|
||||
? view.sections.slice(0, 60).map((s) => ({ name: String(s?.name || ''), color: s?.color || null }))
|
||||
: [],
|
||||
beats: Number.isFinite(Number(view?.pos?.beats)) ? Number(view.pos.beats) : null,
|
||||
itemId: song ? absItemFor(song.name) : '',
|
||||
at: Date.now(),
|
||||
};
|
||||
broadcastAbleSet();
|
||||
}
|
||||
|
||||
/** Only the playhead moved: keep everything else and restamp. */
|
||||
function applyAbleSetPos(pos) {
|
||||
if (!abs.available) return;
|
||||
abs = {
|
||||
...abs,
|
||||
beats: Number.isFinite(Number(pos?.beats)) ? Number(pos.beats) : abs.beats,
|
||||
at: Date.now(),
|
||||
};
|
||||
broadcastAbleSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan the slice out. A change in anything but the playhead goes at once;
|
||||
* the playhead itself is throttled, since AbleSet offers more packets
|
||||
* than any tile can draw.
|
||||
*/
|
||||
function broadcastAbleSet(force) {
|
||||
const { beats, at, ...still } = abs;
|
||||
const frame = JSON.stringify(still);
|
||||
const changed = frame !== absLastStill;
|
||||
absLastStill = frame;
|
||||
const now = Date.now();
|
||||
if (!force && !changed && now - absLastSent < ABLESET_POS_MS) {
|
||||
// Due soon anyway — make sure the last packet of a burst still lands.
|
||||
if (!absPosTimer) {
|
||||
absPosTimer = setTimeout(() => { absPosTimer = null; broadcastAbleSet(true); }, ABLESET_POS_MS);
|
||||
absPosTimer.unref?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearTimeout(absPosTimer);
|
||||
absPosTimer = null;
|
||||
absLastSent = now;
|
||||
if (!streams.size) return;
|
||||
writeAll(streams, `event: ableset\ndata: ${JSON.stringify(abs)}\n\n`);
|
||||
}
|
||||
|
||||
function absUnavailable(reason, retryMs) {
|
||||
if (abs.available || abs.reason !== reason) {
|
||||
abs = { ...clearedAbleSet(), reason };
|
||||
broadcastAbleSet(true);
|
||||
}
|
||||
if (stopped) return;
|
||||
clearTimeout(absRetryTimer);
|
||||
absRetryTimer = setTimeout(connectAbleSet, retryMs);
|
||||
absRetryTimer.unref?.();
|
||||
}
|
||||
|
||||
function connectAbleSet() {
|
||||
if (stopped) return;
|
||||
const port = nnPort;
|
||||
const req = http.get(
|
||||
{ host: '127.0.0.1', port, path: `/api/modules/${ABLESET_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return absUnavailable(
|
||||
res.statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${res.statusCode}`,
|
||||
ABLESET_MISSING_RETRY_MS
|
||||
);
|
||||
}
|
||||
res.setEncoding('utf8');
|
||||
let buffer = '';
|
||||
res.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||
const block = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
try {
|
||||
if (event === 'state') applyAbleSet(JSON.parse(data).state ?? JSON.parse(data));
|
||||
else if (event === 'pos') applyAbleSetPos(JSON.parse(data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
}
|
||||
});
|
||||
res.on('end', () => absUnavailable('AbleSet module feed ended', ABLESET_RETRY_MS));
|
||||
res.on('error', () => absUnavailable('AbleSet module feed dropped', ABLESET_RETRY_MS));
|
||||
}
|
||||
);
|
||||
req.on('error', (err) => {
|
||||
if (req !== absRequest) return; // superseded by a reconnect on the learned port
|
||||
absUnavailable(`ProdDash loopback failed: ${err.message}`, ABLESET_RETRY_MS);
|
||||
});
|
||||
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||||
absRequest = req;
|
||||
}
|
||||
|
||||
function learnPort(hostHeader) {
|
||||
if (shellPort) return; // the shell told us; nothing to learn
|
||||
const port = portFromHost(hostHeader);
|
||||
@@ -1269,6 +1472,10 @@ module.exports = {
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
nnRequest = null;
|
||||
connectNowNext();
|
||||
clearTimeout(absRetryTimer);
|
||||
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||
absRequest = null;
|
||||
connectAbleSet();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1527,6 +1734,9 @@ module.exports = {
|
||||
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||
connectNowNext();
|
||||
}
|
||||
// Always: whether there is an AbleSet module to follow is its answer, not
|
||||
// an admin setting here.
|
||||
connectAbleSet();
|
||||
mirrorService();
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
@@ -1559,6 +1769,7 @@ module.exports = {
|
||||
timerStreams,
|
||||
pco,
|
||||
getState: () => state,
|
||||
getAbleSet: () => abs,
|
||||
learnPort,
|
||||
refresh() {
|
||||
clearTimeout(pollTimer);
|
||||
@@ -1580,7 +1791,10 @@ module.exports = {
|
||||
clearInterval(ticker);
|
||||
clearTimeout(pollTimer);
|
||||
clearTimeout(nnRetryTimer);
|
||||
clearTimeout(absRetryTimer);
|
||||
clearTimeout(absPosTimer);
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||
for (const set of [streams, timerStreams]) {
|
||||
for (const res of set) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
@@ -1622,7 +1836,11 @@ module.exports = {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
current?.learnPort(req.headers.host);
|
||||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||
json(res, 200, {
|
||||
state: current ? current.getState() : clearedState(),
|
||||
ableset: current ? current.getAbleSet() : clearedAbleSet(),
|
||||
now: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
@@ -1630,6 +1848,7 @@ module.exports = {
|
||||
sseHead(res);
|
||||
const state = current ? current.getState() : clearedState();
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||||
res.write(`event: ableset\ndata: ${JSON.stringify(current ? current.getAbleSet() : clearedAbleSet())}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
@@ -589,3 +590,70 @@
|
||||
|
||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
|
||||
|
||||
|
||||
/* ── AbleSet under the current item ───────────────────────────────────
|
||||
Drawn only while an AbleSet module is configured, reachable, and playing
|
||||
the song this item is. The fill is a layer behind the row's own content;
|
||||
everything else in the row is lifted over it. */
|
||||
|
||||
[data-module="pco-plan"] .pp-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 0;
|
||||
border-radius: 0.5em 0 0 0.5em;
|
||||
background: color-mix(in srgb, var(--pp-current) 26%, transparent);
|
||||
/* Packets arrive about five times a second; the glide covers the gaps. */
|
||||
transition: width 0.22s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; }
|
||||
|
||||
/* The sections strip, bottom right of the current item. */
|
||||
[data-module="pco-plan"] .pp-abs-sections {
|
||||
display: flex;
|
||||
gap: 0.3em;
|
||||
/* Bottom right of the item: hug the content and sit against the right edge
|
||||
until there are more sections than fit, when it fills the width and
|
||||
scrolls instead. */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 0.35em;
|
||||
margin-left: auto;
|
||||
padding-bottom: 0.1em;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
|
||||
mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pp-abs-sec {
|
||||
flex: none;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.72em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
||||
|
||||
/* The section now playing fills left to right as it goes, like the row. */
|
||||
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
|
||||
transparent var(--pp-sec-fill, 0%));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const DEFAULTS = {
|
||||
showDetail: true,
|
||||
showSeconds: true,
|
||||
leadingZeros: false,
|
||||
showLoops: false,
|
||||
clock24h: true,
|
||||
clockSeconds: true,
|
||||
clockDate: false,
|
||||
@@ -123,10 +124,13 @@ export function framesToTc(n, nominal, df) {
|
||||
* elapsed ms otherwise, or null when the snapshot can't say. The values as
|
||||
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
|
||||
* forward while running; `startedAt` is the fallback when they are absent.
|
||||
* A timer measured at its own instant carries `asOf` (a phase-locked
|
||||
* ProPresenter value — see the server's phase.js) which beats the source's
|
||||
* snapshot time.
|
||||
*/
|
||||
export function valueAt(timer, sourceUpdatedAt, now) {
|
||||
const running = timer.state === 'running';
|
||||
const at = Number(sourceUpdatedAt) || now;
|
||||
const at = Number(timer.asOf) || Number(sourceUpdatedAt) || now;
|
||||
const dt = running ? Math.max(0, now - at) : 0;
|
||||
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
||||
if (timer.kind === 'countdown') {
|
||||
@@ -381,7 +385,12 @@ export default function create({ root, moduleApi }) {
|
||||
for (const source of state.sources) {
|
||||
for (const timer of source.timers) {
|
||||
if (keyOf(source, timer) !== solo) continue;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||
// A solo video tile with looping media hidden must not sit empty:
|
||||
// it shows a quiet card instead of a countdown nobody should read.
|
||||
const shown = timer.looping && pref('showLoops') !== true
|
||||
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
|
||||
: timer;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
|
||||
}
|
||||
}
|
||||
// The timer this tile was added for is not offered right now. Nothing is
|
||||
@@ -395,7 +404,11 @@ export default function create({ root, moduleApi }) {
|
||||
const items = [];
|
||||
for (const timer of source.timers) {
|
||||
const key = keyOf(source, timer);
|
||||
if (!hidden.has(key)) items.push({ key, source, timer });
|
||||
if (hidden.has(key)) continue;
|
||||
// A looping background's time means nothing — the card sits out
|
||||
// while a loop plays (per tile: the "Looping videos" switch).
|
||||
if (timer.looping && pref('showLoops') !== true) continue;
|
||||
items.push({ key, source, timer });
|
||||
}
|
||||
// Every timer of a live source hidden on purpose: nothing to say.
|
||||
if (!items.length && source.timers.length) continue;
|
||||
@@ -647,9 +660,25 @@ export default function create({ root, moduleApi }) {
|
||||
statusTone = timer.status?.tone || 'muted';
|
||||
}
|
||||
} else {
|
||||
const value = valueAt(timer, source.updatedAt, now);
|
||||
let value = valueAt(timer, source.updatedAt, now);
|
||||
// A running card must never tick backwards: an anchor correction or
|
||||
// clock-offset wobble can re-place a second boundary by a few
|
||||
// hundred ms, and a digit that regresses reads as broken. A small
|
||||
// step against the direction of travel holds the shown value (time
|
||||
// re-passes it within the jitter bound); a big one is a real
|
||||
// reset/seek and snaps.
|
||||
if (timer.state === 'running' && value !== null) {
|
||||
const dir = timer.kind === 'countdown' ? -1 : 1;
|
||||
const prev = card.mono;
|
||||
if (prev && (value - prev) * dir < 0 && Math.abs(value - prev) < 1500) value = prev;
|
||||
card.mono = value;
|
||||
} else {
|
||||
card.mono = null;
|
||||
}
|
||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||
tone = toneFor(timer, value);
|
||||
// A shown loop never warns or overruns — it wraps at zero by design,
|
||||
// and a background flashing red every pass would cry wolf all service.
|
||||
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
|
||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||
statusTone = timer.status?.tone || tone;
|
||||
detailText = timer.detail || '';
|
||||
|
||||
@@ -10,13 +10,23 @@
|
||||
* onUpdate roughly 4×/s:
|
||||
*
|
||||
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||
* { error: '<what is wrong and how to fix it>' } not capturing
|
||||
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
|
||||
*
|
||||
* The capture binary is per-machine (gitignored): if it is missing this
|
||||
* tries one swiftc compile of the source next to it, and otherwise says to
|
||||
* build it on any same-architecture Mac and copy it over. A dead child is
|
||||
* restarted with backoff — fast for a crash, slow for config-shaped
|
||||
* failures (unknown device, ambiguous name) that never fix themselves.
|
||||
* Error strings are two-tier on purpose: `error` is what a dashboard tile
|
||||
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||
* for the admin health line and the log.
|
||||
*
|
||||
* The capture binary is per-machine (gitignored), and a catalog install or
|
||||
* update ships source only — the update even wipes a previously built
|
||||
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
||||
* background as part of coming up: kicked when the LTC source starts and by
|
||||
* the admin device picker, never on the event loop (a blocking build once
|
||||
* froze the whole server — see spl-meter's history), built to a temp name
|
||||
* and renamed so a killed build can't leave a half-written "binary", and
|
||||
* rebuilt when a module update leaves the source newer than the binary.
|
||||
* A dead capture child is restarted with backoff — fast for a crash, slow
|
||||
* for config-shaped failures (unknown device, ambiguity) that never fix
|
||||
* themselves.
|
||||
*
|
||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||
@@ -27,7 +37,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { spawn } = require('child_process');
|
||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
@@ -41,6 +51,100 @@ const RUNNING_WINDOW_MS = 400;
|
||||
/** stderr line the capture prints once the device is open. */
|
||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||
|
||||
/* ── the capture tool: build it when the install didn't bring one ──────── */
|
||||
|
||||
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||
const TOOL_BUILDING = 'Capture tool building…';
|
||||
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
||||
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
||||
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
||||
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
|
||||
/**
|
||||
* Make sure the capture tool exists, building it in the background when it
|
||||
* does not — module scope, so the LTC source can kick it the moment it
|
||||
* starts and the admin device picker can kick it before any device is
|
||||
* configured (a listener-only build would deadlock: no tool → no device
|
||||
* list → no device → no listener). A catalog install ships source only and
|
||||
* a catalog update deletes the module folder (old binary included), so this
|
||||
* IS the install step for the tool.
|
||||
*
|
||||
* Never blocks (a synchronous build once held the whole server's event loop
|
||||
* — every module — for the duration). Builds to a temp name and renames, so
|
||||
* a killed or crashed build can't leave a truncated file that looks like a
|
||||
* ready binary. A binary older than the source (a module update over an
|
||||
* existing folder) keeps serving while a replacement builds behind it.
|
||||
*
|
||||
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
||||
* is usable right now; otherwise `error` is the short tile text and
|
||||
* `detail` the full one.
|
||||
*/
|
||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||
const bin = path.join(moduleDir, 'ltc-capture');
|
||||
const src = bin + '.swift';
|
||||
const ok = { ready: true, error: '', detail: '' };
|
||||
let haveBin = false;
|
||||
let stale = false;
|
||||
try {
|
||||
haveBin = fs.existsSync(bin);
|
||||
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
||||
} catch { /* treat as missing */ }
|
||||
if (haveBin && !stale) return ok;
|
||||
if (!fs.existsSync(src)) {
|
||||
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
||||
}
|
||||
const notYet = haveBin ? ok : {
|
||||
ready: false,
|
||||
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
||||
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
||||
};
|
||||
if (building || buildFailed) return notYet;
|
||||
|
||||
building = true;
|
||||
const tmp = bin + '.building';
|
||||
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
||||
: 'ltc-capture binary missing — building it in the background');
|
||||
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let buildErr = '';
|
||||
build.stderr.setEncoding('utf8');
|
||||
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||
const giveUp = setTimeout(() => {
|
||||
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}, 300000);
|
||||
giveUp.unref?.();
|
||||
build.on('error', (err) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
buildFailed = true;
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
log(`swiftc could not be run: ${err.message}`);
|
||||
});
|
||||
build.on('exit', (code) => {
|
||||
clearTimeout(giveUp);
|
||||
building = false;
|
||||
if (code === 0 && fs.existsSync(tmp)) {
|
||||
try {
|
||||
fs.renameSync(tmp, bin);
|
||||
} catch (err) {
|
||||
buildFailed = true;
|
||||
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
buildFailed = false;
|
||||
log('ltc-capture built');
|
||||
if (typeof onReady === 'function') onReady();
|
||||
return;
|
||||
}
|
||||
buildFailed = true;
|
||||
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||
});
|
||||
return notYet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||
* onUpdate:(u:object)=>void}} opts
|
||||
@@ -75,25 +179,24 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
return 'nosignal';
|
||||
}
|
||||
|
||||
function ensureBinary() {
|
||||
if (fs.existsSync(bin)) return '';
|
||||
const src = bin + '.swift';
|
||||
log('ltc-capture binary missing — trying a one-time swiftc build');
|
||||
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
|
||||
if (built.status === 0 && fs.existsSync(bin)) {
|
||||
log('ltc-capture compiled');
|
||||
return '';
|
||||
}
|
||||
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
|
||||
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
|
||||
+ 'and copy it here';
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopped) return;
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
report({ error: missing });
|
||||
const tool = ensureCaptureBinary({
|
||||
moduleDir,
|
||||
log,
|
||||
onReady() {
|
||||
// The build this start() was waiting on landed — go now.
|
||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||
},
|
||||
});
|
||||
if (!tool.ready) {
|
||||
report({ error: tool.error, detail: tool.detail });
|
||||
// onReady is only the FAST path (a successful build restarts us at
|
||||
// once). The slow retry must stay armed regardless: a failed build
|
||||
// never calls back, and a binary can also arrive from outside — scp'd
|
||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||
// Waiting on onReady alone deadlocked exactly that way in production:
|
||||
// "Capture tool building…" forever, with a working binary on disk.
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +216,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
const channels = Number(m[2]);
|
||||
const rate = Number(m[3]);
|
||||
if (channel > channels) {
|
||||
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
||||
report({
|
||||
error: 'Channel not on this device — see Admin',
|
||||
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
|
||||
});
|
||||
try { child.kill(); } catch { /* exiting anyway */ }
|
||||
return;
|
||||
}
|
||||
@@ -135,17 +241,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
report({ error: `LTC capture failed to start: ${err.message}` });
|
||||
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
child = null;
|
||||
if (stopped) return;
|
||||
// The capture's own failure text (unknown device, ambiguous name,
|
||||
// permission hint) is the most useful thing we can show.
|
||||
// permission hint) is the most useful DETAIL we have; the tile just
|
||||
// needs to know the input is down and where to fix it.
|
||||
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||
const quickDeath = Date.now() - startedAt < 3000;
|
||||
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
||||
report({
|
||||
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
|
||||
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
|
||||
});
|
||||
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||
});
|
||||
}
|
||||
@@ -188,4 +298,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLtcListener };
|
||||
module.exports = { createLtcListener, ensureCaptureBinary };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
@@ -161,6 +161,12 @@
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showLoops": {
|
||||
"type": "switch",
|
||||
"label": "Looping videos",
|
||||
"default": false,
|
||||
"group": "Show"
|
||||
},
|
||||
"showHeadings": {
|
||||
"type": "switch",
|
||||
"label": "Source headings",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Phase lock for whole-second timer reports (the ProPresenter poller).
|
||||
*
|
||||
* ProPresenter reports a running timer as whole seconds — trunc(value):
|
||||
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
|
||||
* old poller re-anchored a tile's local count at the bottom of that interval
|
||||
* on every poll, so the displayed digit ran up to a second ahead and then
|
||||
* got yanked back a step whenever a fresh poll happened to sample the next
|
||||
* flip later than the last one did — the poll clock and ProPresenter's
|
||||
* second boundaries drift through each other, so tiles flickered
|
||||
* "…12:33 → 12:34 → 12:33…" about once a second.
|
||||
*
|
||||
* This tracker turns the quantized reports into ONE stable anchor
|
||||
* { valueMs, at } — the continuous value at a fixed instant — which the
|
||||
* client extrapolates from. Rules:
|
||||
*
|
||||
* - The only real information in a poll is a FLIP: the report stepping one
|
||||
* second in the direction of travel. At the flip instant the true value
|
||||
* equals the interval boundary just crossed, and the flip happened between
|
||||
* the last two polls, so their midpoint dates it to within half a poll.
|
||||
* - While a poll's report matches what the anchor predicts (within TOL_MS
|
||||
* around the reported second's interval), the anchor HOLDS — the same
|
||||
* numbers are republished, so consumers count along one steady clock
|
||||
* instead of a freshly re-phased one per sample.
|
||||
* - A report the anchor can't explain re-anchors: at the flip boundary when
|
||||
* this poll saw a clean one-second flip, else mid-interval (first sight,
|
||||
* an operator reset/seek, a resume, or clock drift past TOL_MS).
|
||||
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
|
||||
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
|
||||
* whole-second bias one way.
|
||||
*
|
||||
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
|
||||
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
|
||||
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
|
||||
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
|
||||
* crosses +1 s.
|
||||
*/
|
||||
|
||||
/** How far a report may sit outside the anchor's prediction before it means
|
||||
something real. A flip is dated to the midpoint of two polls, so its own
|
||||
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
|
||||
runs long); the tolerance must sit ABOVE that or consecutive flips
|
||||
re-anchor each other forever, and safely below a full second so a real
|
||||
one-second seek still registers. */
|
||||
const TOL_MS = 450;
|
||||
|
||||
/** The continuous-value interval implied by a whole-second report. */
|
||||
function intervalFor(kind, wholeMs) {
|
||||
if (kind === 'countdown') {
|
||||
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
|
||||
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
|
||||
}
|
||||
return { lo: wholeMs, hi: wholeMs + 1000 };
|
||||
}
|
||||
|
||||
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
|
||||
function boundaryFor(kind, wholeMs) {
|
||||
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
|
||||
return wholeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tracker per timer. Feed one observation per poll; get back what to
|
||||
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
|
||||
* stable across polls while the reports stay consistent with it. Not
|
||||
* running (or no value): the report is returned verbatim, dated at the poll.
|
||||
*/
|
||||
function createTimerPhase() {
|
||||
let anchor = null; // { valueMs, at }
|
||||
let kind = '';
|
||||
let lastWhole = null;
|
||||
let lastAt = 0;
|
||||
let wasRunning = false;
|
||||
|
||||
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
|
||||
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
|
||||
const dir = k === 'countdown' ? -1 : 1;
|
||||
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
|
||||
&& lastWhole !== null && wholeMs !== null
|
||||
&& wholeMs - lastWhole === dir * 1000;
|
||||
const prevAt = lastAt;
|
||||
lastWhole = wholeMs;
|
||||
lastAt = pollAt;
|
||||
wasRunning = Boolean(running);
|
||||
if (k !== kind) anchor = null;
|
||||
kind = k;
|
||||
if (!running || wholeMs === null) {
|
||||
anchor = null;
|
||||
return { valueMs: wholeMs, asOf: pollAt };
|
||||
}
|
||||
const { lo, hi } = intervalFor(k, wholeMs);
|
||||
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
|
||||
// tolerance check may fail to catch for a long time (its error only
|
||||
// shows at a poll landing more than TOL_MS past a flip). The first
|
||||
// clean flip is strictly better evidence (±½ poll), so it always wins.
|
||||
if (anchor && !(anchor.provisional && flip)) {
|
||||
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
|
||||
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
|
||||
return { valueMs: anchor.valueMs, asOf: anchor.at };
|
||||
}
|
||||
}
|
||||
anchor = flip
|
||||
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
|
||||
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
|
||||
return { valueMs: anchor.valueMs, asOf: anchor.at };
|
||||
}
|
||||
|
||||
return { observe };
|
||||
}
|
||||
|
||||
module.exports = { createTimerPhase, TOL_MS };
|
||||
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-test for phase.js — the whole-second phase lock — plus the client's
|
||||
* never-tick-backwards guard, driven by a simulated ProPresenter:
|
||||
*
|
||||
* node modules/propresenter-timers/phase.selftest.js
|
||||
*
|
||||
* The simulation runs a true continuous timer, reports it the way
|
||||
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
|
||||
* jitter), feeds the reports through createTimerPhase(), then renders the
|
||||
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
|
||||
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
|
||||
*
|
||||
* - the displayed digits NEVER step against the direction of travel
|
||||
* (the reported bug: countdown tiles flicking back to the previous
|
||||
* second about once a second),
|
||||
* - each displayed second dwells ~1 s,
|
||||
* - the published anchor stays within tolerance of the true value,
|
||||
* - operator resets/seeks snap within a poll or two,
|
||||
* - pause → resume re-locks,
|
||||
* - a ProPresenter whose clock drifts against the server's stays locked.
|
||||
*/
|
||||
|
||||
const { createTimerPhase, TOL_MS } = require('./phase');
|
||||
|
||||
let checks = 0;
|
||||
let failures = 0;
|
||||
function ok(name, cond, detail) {
|
||||
checks += 1;
|
||||
if (!cond) {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic pseudo-random (so a failure reproduces). */
|
||||
function makeRand(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
/** ProPresenter's report: whole seconds, truncated toward zero. */
|
||||
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
|
||||
|
||||
/**
|
||||
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
|
||||
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
|
||||
* timers report a frozen value via `frozen`).
|
||||
*/
|
||||
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
|
||||
const rand = makeRand(seed);
|
||||
const phase = createTimerPhase();
|
||||
const dir = kind === 'countdown' ? -1 : 1;
|
||||
|
||||
const polls = [];
|
||||
for (let t = 0; t <= durationMs;) {
|
||||
const running = runningAt(t);
|
||||
const value = trueValueAt(t);
|
||||
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
|
||||
t += pollMs + Math.floor(rand() * jitterMs);
|
||||
}
|
||||
|
||||
// Render like client.js: between polls, extrapolate from the latest
|
||||
// anchor; clamp sub-1.5 s regressions while running.
|
||||
const shown = []; // { t, sec, err }
|
||||
let mono = null;
|
||||
let p = 0;
|
||||
for (let t = polls[0].t; t <= durationMs; t += 25) {
|
||||
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
|
||||
const { out, running } = polls[p];
|
||||
if (!running || out.valueMs === null) {
|
||||
mono = null;
|
||||
continue;
|
||||
}
|
||||
let value = out.valueMs + dir * (t - out.asOf);
|
||||
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
|
||||
mono = value;
|
||||
const truth = trueValueAt(t);
|
||||
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
|
||||
}
|
||||
return { name, dir, shown };
|
||||
}
|
||||
|
||||
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
|
||||
from legitimate corrections (absorbing clock drift re-places a boundary
|
||||
by up to TOL_MS once in a while). */
|
||||
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
|
||||
let regressions = 0;
|
||||
let maxAbsErr = 0;
|
||||
const changes = [];
|
||||
for (let i = 1; i < shown.length; i += 1) {
|
||||
const step = shown[i].sec - shown[i - 1].sec;
|
||||
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
|
||||
if (step !== 0) changes.push(shown[i].t);
|
||||
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
|
||||
}
|
||||
// Whole dwells only (drop the partial first and trailing segments), after
|
||||
// the lock settles — the first observed flip corrects the provisional
|
||||
// mid-interval anchor once, stretching or squeezing a boundary in the
|
||||
// timer's first moments, which no one can see on a wall. A countdown
|
||||
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
|
||||
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
|
||||
// allow one ~2 s dwell; everything else must be ~1 s.
|
||||
const settled = shown.length ? shown[0].t + 2500 : 0;
|
||||
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
|
||||
const near = (d, target) => Math.abs(d - target) <= 200;
|
||||
const long = dwells.filter((d) => near(d, 2000)).length;
|
||||
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
|
||||
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
|
||||
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
|
||||
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
|
||||
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
|
||||
}
|
||||
|
||||
/* 1. Plain countdown, many sub-second phases and poll seeds. */
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const phase0 = (i * 83) % 1000;
|
||||
const run = simulate({
|
||||
name: `countdown q=${phase0}ms seed=${i}`,
|
||||
kind: 'countdown',
|
||||
seed: 1000 + i,
|
||||
durationMs: 30000,
|
||||
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
|
||||
});
|
||||
assertSteady(run);
|
||||
}
|
||||
|
||||
/* 2. Elapsed timer (counts up). */
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const run = simulate({
|
||||
name: `elapsed seed=${i}`,
|
||||
kind: 'elapsed',
|
||||
seed: 2000 + i,
|
||||
durationMs: 25000,
|
||||
trueValueAt: (t) => 400 + i * 137 + t,
|
||||
});
|
||||
assertSteady(run);
|
||||
}
|
||||
|
||||
/* 3. Operator reset mid-run: value jumps back up to full length. */
|
||||
{
|
||||
const run = simulate({
|
||||
name: 'reset',
|
||||
kind: 'countdown',
|
||||
seed: 7,
|
||||
durationMs: 24000,
|
||||
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
|
||||
});
|
||||
// After the reset the shown value must reach the new region within ~2 polls.
|
||||
const after = run.shown.filter((s) => s.t >= 13400);
|
||||
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
|
||||
// Before and after the jump, digits stay monotonic within each phase.
|
||||
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
|
||||
assertSteady(pre, { maxErr: 700 });
|
||||
}
|
||||
|
||||
/* 4. Pause, then resume: no stale-anchor explosion. */
|
||||
{
|
||||
const frozen = 8000;
|
||||
const run = simulate({
|
||||
name: 'pause/resume',
|
||||
kind: 'countdown',
|
||||
seed: 11,
|
||||
durationMs: 26000,
|
||||
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
|
||||
runningAt: (t) => t < 6000 || t >= 14000,
|
||||
});
|
||||
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
|
||||
assertSteady(resumed);
|
||||
}
|
||||
|
||||
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
|
||||
re-locks over time instead of accumulating a visible error. */
|
||||
{
|
||||
const run = simulate({
|
||||
name: 'clock drift',
|
||||
kind: 'countdown',
|
||||
seed: 13,
|
||||
durationMs: 120000,
|
||||
trueValueAt: (t) => 130000 - t * 1.002,
|
||||
});
|
||||
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
|
||||
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
|
||||
// a backwards step (the separate regression check still demands zero).
|
||||
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
|
||||
}
|
||||
|
||||
/* 6. Idle timers pass through untouched. */
|
||||
{
|
||||
const phase = createTimerPhase();
|
||||
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
|
||||
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
|
||||
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
|
||||
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} of ${checks} checks FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`phase.selftest: all ${checks} checks passed`);
|
||||
@@ -81,7 +81,8 @@
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { createLtcListener } = require('./ltc-listener');
|
||||
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
||||
const { createTimerPhase } = require('./phase');
|
||||
const manifest = require('./module.json');
|
||||
|
||||
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
||||
@@ -209,6 +210,7 @@ function sanitizeTimer(t) {
|
||||
label: String(t.label || id).slice(0, 120),
|
||||
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
|
||||
state: STATES.has(t.state) ? t.state : 'stopped',
|
||||
asOf: numOrNull(t.asOf),
|
||||
startedAt: numOrNull(t.startedAt),
|
||||
elapsedMs: numOrNull(t.elapsedMs),
|
||||
remainingMs: numOrNull(t.remainingMs),
|
||||
@@ -250,6 +252,23 @@ function createProPresenterSource({ config, log }) {
|
||||
let updatedAt = 0;
|
||||
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
|
||||
let timerConfigs = new Map();
|
||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||
const phases = new Map();
|
||||
|
||||
/* The video countdown — time left on what the presentation layer is
|
||||
playing, from the transport routes (verified live on ProPresenter 21.4):
|
||||
GET /v1/transport/presentation/current → { is_playing, uuid, name,
|
||||
artist, audio_only, duration } (no looping flag exists — inferred)
|
||||
GET /v1/transport/presentation/time → bare seconds, sub-second
|
||||
float — better than /v1/timer/video_countdown's whole-second string,
|
||||
so no quantization phase lock is needed, only an anchor hold. */
|
||||
let media = null; // { uuid, name, duration, playing } from the slow lane
|
||||
let mediaAt = 0;
|
||||
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
|
||||
let videoPos = null; // playback position in seconds (float)
|
||||
let videoAsOf = 0; // server ms when videoPos was measured
|
||||
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
|
||||
let loopingUuid = ''; // media seen wrapping — a looping background
|
||||
|
||||
function url(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
@@ -325,9 +344,21 @@ function createProPresenterSource({ config, log }) {
|
||||
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
|
||||
state = atStart ? 'idle' : 'paused';
|
||||
}
|
||||
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
||||
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
||||
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
|
||||
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
|
||||
// local count at the bottom of that second every poll made the digits run
|
||||
// ahead and flick back a step about once a second — phase.js turns the
|
||||
// reports into one stable anchor { valueMs, asOf } the tiles count from.
|
||||
let phase = phases.get(uuid);
|
||||
if (!phase) {
|
||||
phase = createTimerPhase();
|
||||
phases.set(uuid, phase);
|
||||
}
|
||||
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
|
||||
const v = anchored.valueMs;
|
||||
const asOf = anchored.asOf;
|
||||
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
|
||||
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
|
||||
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
|
||||
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
|
||||
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
||||
return {
|
||||
@@ -335,6 +366,7 @@ function createProPresenterSource({ config, log }) {
|
||||
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
||||
kind,
|
||||
state,
|
||||
asOf,
|
||||
startedAt,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
@@ -367,16 +399,143 @@ function createProPresenterSource({ config, log }) {
|
||||
// Timers reported live but missing from the (possibly stale) config
|
||||
// list still deserve a card — type unknown until the slow lane catches up.
|
||||
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
|
||||
for (const uuid of phases.keys()) {
|
||||
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the presentation transport layer. Failures here only drop the
|
||||
* video card, never the whole source — the timers must survive a
|
||||
* ProPresenter build with transport quirks.
|
||||
*/
|
||||
async function pollVideo() {
|
||||
if (tick % SLOW_EVERY === 1) {
|
||||
try {
|
||||
const raw = await requestJson('/v1/transport/presentation/current');
|
||||
const uuid = String(raw?.uuid || '');
|
||||
if (!media || media.uuid !== uuid) {
|
||||
if (media && media.uuid !== uuid) loopingUuid = '';
|
||||
videoPos = null;
|
||||
videoAnchor = null;
|
||||
}
|
||||
media = uuid ? {
|
||||
uuid,
|
||||
name: String(raw.name || ''),
|
||||
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
|
||||
playing: Boolean(raw.is_playing),
|
||||
} : null;
|
||||
mediaAt = Date.now();
|
||||
transportOk = true;
|
||||
} catch (err) {
|
||||
media = null; // the slow lane retries in ~2 s
|
||||
if (err && err.status === 404) transportOk = false; // no transport route on this build
|
||||
}
|
||||
}
|
||||
if (!media) {
|
||||
videoPos = null;
|
||||
videoAnchor = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pos = Number(await requestJson('/v1/transport/presentation/time'));
|
||||
if (Number.isFinite(pos) && pos >= 0) {
|
||||
// Same media, position jumped back by more than a stutter: it
|
||||
// wrapped — a looping background. Sticks until the media changes.
|
||||
// (A video manually re-triggered from the start looks the same and
|
||||
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
|
||||
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
|
||||
videoPos = pos;
|
||||
videoAsOf = Date.now();
|
||||
} else {
|
||||
videoPos = null;
|
||||
}
|
||||
} catch {
|
||||
videoPos = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The video countdown as one synthetic timer card (id 'video'). The card
|
||||
persists as an idle "—" between videos — a wall tile must not appear
|
||||
and vanish with the media — and is only absent entirely on a
|
||||
ProPresenter without transport routes. */
|
||||
function videoTimer() {
|
||||
if (transportOk !== true) return null;
|
||||
if (!media) {
|
||||
return {
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
kind: 'countdown',
|
||||
state: 'idle',
|
||||
asOf: mediaAt || updatedAt,
|
||||
startedAt: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: null,
|
||||
targetMs: null,
|
||||
looping: false,
|
||||
status: null,
|
||||
detail: '',
|
||||
};
|
||||
}
|
||||
const playing = media.playing;
|
||||
const dur = media.duration > 0 ? media.duration * 1000 : null;
|
||||
let remainingMs = null;
|
||||
let asOf = videoAsOf || mediaAt;
|
||||
if (videoPos !== null && dur !== null) {
|
||||
remainingMs = Math.max(0, dur - videoPos * 1000);
|
||||
if (playing) {
|
||||
// Hold the published anchor while it still predicts the measured
|
||||
// position: the tiles extrapolate locally, and a fresh anchor per
|
||||
// poll would broadcast a frame every 500 ms for nothing.
|
||||
const p = videoAnchor;
|
||||
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
|
||||
if (p && Math.abs(predicted - remainingMs) < 120) {
|
||||
remainingMs = p.remainingMs;
|
||||
asOf = p.asOf;
|
||||
} else {
|
||||
videoAnchor = { remainingMs, asOf };
|
||||
}
|
||||
} else {
|
||||
videoAnchor = null;
|
||||
}
|
||||
} else {
|
||||
videoAnchor = null;
|
||||
}
|
||||
const looping = media.uuid === loopingUuid;
|
||||
return {
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
kind: 'countdown',
|
||||
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
|
||||
asOf,
|
||||
startedAt: null,
|
||||
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
|
||||
remainingMs,
|
||||
targetMs: dur,
|
||||
looping,
|
||||
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
|
||||
// The media name, without the file extension nobody reads on a wall.
|
||||
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function status() {
|
||||
if (!configured) return 'error';
|
||||
if (reachable) return 'ok';
|
||||
return lastError ? 'error' : 'connecting';
|
||||
}
|
||||
|
||||
function message() {
|
||||
/** Short, for the tiles — a dashboard warns, the admin page explains.
|
||||
The client prefixes the source label, so no "ProPresenter" here. */
|
||||
function tileMessage() {
|
||||
if (!configured) return 'Not configured — see Admin';
|
||||
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||
return 'Unreachable';
|
||||
}
|
||||
|
||||
/** The full story, for the admin health line. */
|
||||
function adminMessage() {
|
||||
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
|
||||
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
||||
@@ -384,7 +543,7 @@ function createProPresenterSource({ config, log }) {
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
emit({ status: status(), message: message(), timers, updatedAt });
|
||||
emit({ status: status(), message: tileMessage(), timers, updatedAt });
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
@@ -400,6 +559,9 @@ function createProPresenterSource({ config, log }) {
|
||||
const currentTimes = await requestJson('/v1/timers/current');
|
||||
updatedAt = Date.now();
|
||||
timers = mergeTimers(currentTimes, updatedAt);
|
||||
await pollVideo();
|
||||
const video = videoTimer();
|
||||
if (video) timers.push(video);
|
||||
reachable = true;
|
||||
lastError = '';
|
||||
} catch (err) {
|
||||
@@ -430,7 +592,7 @@ function createProPresenterSource({ config, log }) {
|
||||
clearTimeout(pollTimer);
|
||||
},
|
||||
health() {
|
||||
return { status: status(), message: message() };
|
||||
return { status: status(), message: adminMessage() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -444,7 +606,9 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
let emit = () => {};
|
||||
let listener = null;
|
||||
let stopped = false;
|
||||
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
|
||||
/** The listener's latest word: { state, time, fps, df, ageMs } |
|
||||
{ error, detail } (error = short tile text, detail = the admin/log
|
||||
story) | null while starting. */
|
||||
let word = null;
|
||||
|
||||
function rateText() {
|
||||
@@ -496,8 +660,16 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
kind: 'ltc',
|
||||
start(e) {
|
||||
emit = e;
|
||||
// Kick the capture-tool build right away: a catalog install ships
|
||||
// source only and a catalog update wipes any previously built binary,
|
||||
// so coming up after an install/update must rebuild it without
|
||||
// waiting for a device to be picked or a listener to need it.
|
||||
ensureCaptureBinary({ moduleDir, log });
|
||||
if (!device) {
|
||||
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
|
||||
word = {
|
||||
error: 'Not configured — see Admin',
|
||||
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
|
||||
};
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
@@ -519,7 +691,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
||||
listener = null;
|
||||
},
|
||||
health() {
|
||||
return { status: status(), message: message() };
|
||||
// The admin page gets the full story; the tiles got the short one.
|
||||
return { status: status(), message: (word && word.error && word.detail) || message() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -568,7 +741,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
||||
let silenceTimer = null;
|
||||
let backoff = BACKOFF_MIN_MS;
|
||||
|
||||
function message() {
|
||||
/** Short, for the tiles; the lastError detail belongs to the admin line.
|
||||
The client prefixes the source label, so it isn't repeated here. */
|
||||
function tileMessage() {
|
||||
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||
if (state === 'connecting') return 'Connecting…';
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
function adminMessage() {
|
||||
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
||||
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
||||
return `unreachable — ${lastError}`;
|
||||
@@ -576,7 +757,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
emit({ label, status: state, message: message(), timers, updatedAt });
|
||||
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
|
||||
}
|
||||
|
||||
function accept(raw) {
|
||||
@@ -754,7 +935,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
||||
if (!stopped && !stream && via === 'poll') connectStream();
|
||||
},
|
||||
health() {
|
||||
return { status: state, message: message() };
|
||||
return { status: state, message: adminMessage() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -922,6 +1103,8 @@ const GEAR = {
|
||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
||||
/** The video countdown: a timer card, plus whether looping media counts. */
|
||||
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
|
||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||
/** The time of day: plain digits, so only how they read. */
|
||||
@@ -935,6 +1118,7 @@ function gearFor(source, timer) {
|
||||
if (source.kind === 'ltc') return GEAR.ltc;
|
||||
if (source.kind === 'clock') return GEAR.clock;
|
||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||
return GEAR.timer;
|
||||
}
|
||||
|
||||
@@ -992,12 +1176,17 @@ module.exports = {
|
||||
for (const t of v.timers) {
|
||||
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
|
||||
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
|
||||
if (t.state === 'running' && v.updatedAt > 0) {
|
||||
const dt = Math.max(0, now - v.updatedAt);
|
||||
// A timer measured at its own instant (asOf — a phase-locked
|
||||
// ProPresenter value) re-anchors from there; the rest from the
|
||||
// source snapshot. Consumers see plain contract values as of `now`.
|
||||
const at = Number(out.asOf) || v.updatedAt;
|
||||
if (t.state === 'running' && at > 0) {
|
||||
const dt = Math.max(0, now - at);
|
||||
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
|
||||
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
|
||||
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
|
||||
}
|
||||
delete out.asOf;
|
||||
timers.push(out);
|
||||
}
|
||||
}
|
||||
@@ -1138,7 +1327,8 @@ module.exports = {
|
||||
for (const t of src.timers) {
|
||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||
: src.kind === 'clock' ? 'The time of day on its own'
|
||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
|
||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
||||
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
||||
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
||||
@@ -1147,7 +1337,7 @@ module.exports = {
|
||||
return out;
|
||||
},
|
||||
|
||||
routes() {
|
||||
routes({ log }) {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
||||
@@ -1170,7 +1360,15 @@ module.exports = {
|
||||
|
||||
// The admin device picker asks this machine what it can hear. The
|
||||
// options shape matches the admin select's optionsRoute contract.
|
||||
// Opening the picker also kicks the capture-tool build when a catalog
|
||||
// install/update left only the source — without this, no tool meant
|
||||
// no device list, and no device meant nothing would ever build it.
|
||||
'GET /ltc/devices': (req, res) => {
|
||||
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||
if (!tool.ready) {
|
||||
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
||||
return;
|
||||
}
|
||||
const bin = path.join(__dirname, 'ltc-capture');
|
||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||
let err = '';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -373,7 +373,18 @@ function mountModule(id) {
|
||||
const log = makeLog(id);
|
||||
try {
|
||||
const serverPath = path.join(man.dir, man.server);
|
||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||
// Drop the module's code from the require cache so every (re)mount runs
|
||||
// the files on disk. Without this a module UPDATE swaps the folder while
|
||||
// the process keeps executing the old code until ProdDash itself
|
||||
// restarts — the manifest (read fresh from disk) then reports a version
|
||||
// the running code isn't, and anything new in the update silently
|
||||
// doesn't happen. Modules already have to come up clean on re-init
|
||||
// (module guide), so a fresh load on config remounts is the same
|
||||
// contract without the trap.
|
||||
const cachePrefix = man.dir.endsWith(path.sep) ? man.dir : man.dir + path.sep;
|
||||
for (const key of Object.keys(require.cache)) {
|
||||
if (key.startsWith(cachePrefix)) delete require.cache[key];
|
||||
}
|
||||
const mod = require(serverPath);
|
||||
const config = effectiveConfig(id);
|
||||
// What a module needs to reach the rest of ProdDash — another module's
|
||||
@@ -1060,13 +1071,22 @@ async function installModule(id) {
|
||||
if (download) {
|
||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
const shipped = new Set();
|
||||
for (const [rel, data] of remoteEntry.files) {
|
||||
if (!safeRelPath(rel)) continue;
|
||||
shipped.add(rel);
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
}
|
||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||
// Per-machine executables — a capture tool compiled here, or copied in
|
||||
// by hand on a machine whose toolchain can't build it — are gitignored,
|
||||
// so the repo never ships them. Replacing the folder must not destroy
|
||||
// them: carry executables the download doesn't include over from the
|
||||
// copy being replaced (or from the app's bundled copy on a first
|
||||
// install), or "update" quietly kills a working module.
|
||||
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
|
||||
unmountModule(id);
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(staging, dest);
|
||||
@@ -1083,6 +1103,43 @@ async function installModule(id) {
|
||||
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy executable files (any execute bit) that `shipped` does not include
|
||||
* from the first existing source dir into `staging`, keeping their relative
|
||||
* path and mode. Executables only, on purpose: they are the per-machine
|
||||
* build artifacts (gitignored, so never in a download), while stray text
|
||||
* files from an old version — code an update deliberately removed — must
|
||||
* NOT be resurrected.
|
||||
*/
|
||||
function carryOverExecutables(sourceDirs, staging, shipped) {
|
||||
const from = sourceDirs.find((d) => {
|
||||
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
||||
});
|
||||
if (!from) return;
|
||||
const walk = (rel) => {
|
||||
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
|
||||
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
walk(relPath);
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
|
||||
try {
|
||||
const stat = fs.statSync(path.join(from, relPath));
|
||||
if (!(stat.mode & 0o111)) continue;
|
||||
const target = path.join(staging, relPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(path.join(from, relPath), target);
|
||||
fs.chmodSync(target, stat.mode);
|
||||
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
|
||||
} catch (err) {
|
||||
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk('');
|
||||
}
|
||||
|
||||
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||
later reinstall picks them up again. */
|
||||
|
||||
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
|
||||
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
|
||||
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
|
||||
|
||||
sendJson(res, { error: 'not found' }, 404);
|
||||
});
|
||||
|
||||
/* ── transport (video countdown): a 4-minute cycle ─────────────────
|
||||
0–60 s "Sermon Bumper.mp4" (3:00) playing
|
||||
60–75 s the same bumper paused at 1:00
|
||||
75–90 s playing again
|
||||
90–120 s nothing loaded
|
||||
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
|
||||
must infer looping from the position jumping back.
|
||||
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
|
||||
name, artist, audio_only, duration }, `time` = bare seconds (float). */
|
||||
function transport() {
|
||||
const phase = ((Date.now() - startedAt) / 1000) % 240;
|
||||
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
|
||||
if (phase < 90) {
|
||||
const playing = phase < 60 || phase >= 75;
|
||||
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
|
||||
return {
|
||||
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
|
||||
time: Math.round(pos * 10) / 10,
|
||||
};
|
||||
}
|
||||
if (phase < 120) return none;
|
||||
return {
|
||||
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
|
||||
time: Math.round(((phase - 120) % 15) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||
|
||||
const cryptoMod = require('crypto');
|
||||
|
||||
Reference in New Issue
Block a user