Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc19ef696 | ||
|
|
86a3270f1c | ||
|
|
b3b7b29007 | ||
|
|
62c34fe80c | ||
|
|
4c6a2b8f76 | ||
|
|
b0defbd56b | ||
|
|
914ff7795b | ||
|
|
076e583daf | ||
|
|
21519dd3da | ||
|
|
4ed22b361b | ||
|
|
aec9c47ed4 |
@@ -303,9 +303,11 @@ below. Nothing is installed without someone clicking.
|
|||||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||||
its item tags, plus tidy titles.
|
its item tags, plus tidy titles.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
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
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
**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
|
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||||
show/hide checklist for every detail.
|
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
|
**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
|
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
|
by the browser; initials when Planning Center has none) and a dot for
|
||||||
|
|||||||
+142
-1
@@ -34,6 +34,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
let ticker = null;
|
let ticker = null;
|
||||||
let lastCurrentId = null;
|
let lastCurrentId = null;
|
||||||
let lastListKey = '';
|
let lastListKey = '';
|
||||||
|
let absView = null; // what the server says AbleSet is doing, if anything
|
||||||
let userScrolledAt = 0;
|
let userScrolledAt = 0;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -493,6 +494,133 @@ function createPlan({ root, moduleApi }) {
|
|||||||
renderLiveMarks();
|
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. */
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||||
function renderLiveMarks() {
|
function renderLiveMarks() {
|
||||||
const live = state?.live || {};
|
const live = state?.live || {};
|
||||||
@@ -531,11 +659,12 @@ function createPlan({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||||
}
|
}
|
||||||
|
renderAbleSet();
|
||||||
if (currentId !== lastCurrentId) {
|
if (currentId !== lastCurrentId) {
|
||||||
lastCurrentId = currentId;
|
lastCurrentId = currentId;
|
||||||
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||||
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
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() {
|
function tick() {
|
||||||
|
renderAbleSet();
|
||||||
if (!state?.plan) return;
|
if (!state?.plan) return;
|
||||||
renderClock();
|
renderClock();
|
||||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||||
@@ -621,6 +751,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||||
state = payload.state;
|
state = payload.state;
|
||||||
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||||
|
if (payload.ableset) absView = payload.ableset;
|
||||||
feedOffline = false;
|
feedOffline = false;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -641,6 +772,16 @@ function createPlan({ root, moduleApi }) {
|
|||||||
accept(JSON.parse(ev.data));
|
accept(JSON.parse(ev.data));
|
||||||
} catch { /* a bad frame is not fatal */ }
|
} 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",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service and team assignments from Planning Center Services",
|
"description": "Order of service and team assignments from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -382,6 +382,18 @@
|
|||||||
"label": "Match line",
|
"label": "Match line",
|
||||||
"default": true,
|
"default": true,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"absProgress": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Song progress",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"absSections": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Song sections",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tiles": [
|
"tiles": [
|
||||||
|
|||||||
+221
-2
@@ -9,6 +9,10 @@
|
|||||||
*
|
*
|
||||||
* GET /state → { state, now } (instant paint for new tiles)
|
* GET /state → { state, now } (instant paint for new tiles)
|
||||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
* 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
|
* POST /refresh → re-read the plan now
|
||||||
* GET /service-types → { options } for the admin "Service type" select,
|
* GET /service-types → { options } for the admin "Service type" select,
|
||||||
* grouped by Planning Center folder path
|
* grouped by Planning Center folder path
|
||||||
@@ -33,6 +37,13 @@
|
|||||||
* GET /service_types/{st}/item_note_categories (note category names)
|
* GET /service_types/{st}/item_note_categories (note category names)
|
||||||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
* 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,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* this module reads the propresenter-now-next module's own SSE feed over
|
||||||
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
||||||
@@ -59,7 +70,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||||
const {
|
const {
|
||||||
MINUTE,
|
MINUTE,
|
||||||
serviceTimes,
|
serviceTimes,
|
||||||
@@ -85,6 +96,12 @@ const HEARTBEAT_MS = 15000;
|
|||||||
/** The Now/Next module whose feed we follow. */
|
/** The Now/Next module whose feed we follow. */
|
||||||
const NOW_NEXT_ID = 'propresenter-now-next';
|
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||||
const NOW_NEXT_RETRY_MS = 3000;
|
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;
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||||
const SEEN_DOCUMENTS_MAX = 200;
|
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. */
|
/** The service that owns the clock, as tiles and the timers see it. */
|
||||||
function clearedService() {
|
function clearedService() {
|
||||||
return {
|
return {
|
||||||
@@ -1258,6 +1297,170 @@ module.exports = {
|
|||||||
const badPorts = new Set();
|
const badPorts = new Set();
|
||||||
|
|
||||||
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
|
/** 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) {
|
function learnPort(hostHeader) {
|
||||||
if (shellPort) return; // the shell told us; nothing to learn
|
if (shellPort) return; // the shell told us; nothing to learn
|
||||||
const port = portFromHost(hostHeader);
|
const port = portFromHost(hostHeader);
|
||||||
@@ -1269,6 +1472,10 @@ module.exports = {
|
|||||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
nnRequest = null;
|
nnRequest = null;
|
||||||
connectNowNext();
|
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';
|
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||||
connectNowNext();
|
connectNowNext();
|
||||||
}
|
}
|
||||||
|
// Always: whether there is an AbleSet module to follow is its answer, not
|
||||||
|
// an admin setting here.
|
||||||
|
connectAbleSet();
|
||||||
mirrorService();
|
mirrorService();
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
@@ -1559,6 +1769,7 @@ module.exports = {
|
|||||||
timerStreams,
|
timerStreams,
|
||||||
pco,
|
pco,
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
|
getAbleSet: () => abs,
|
||||||
learnPort,
|
learnPort,
|
||||||
refresh() {
|
refresh() {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
@@ -1580,7 +1791,10 @@ module.exports = {
|
|||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
clearTimeout(nnRetryTimer);
|
clearTimeout(nnRetryTimer);
|
||||||
|
clearTimeout(absRetryTimer);
|
||||||
|
clearTimeout(absPosTimer);
|
||||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||||
for (const set of [streams, timerStreams]) {
|
for (const set of [streams, timerStreams]) {
|
||||||
for (const res of set) {
|
for (const res of set) {
|
||||||
try { res.end(); } catch { /* already gone */ }
|
try { res.end(); } catch { /* already gone */ }
|
||||||
@@ -1622,7 +1836,11 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
current?.learnPort(req.headers.host);
|
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) => {
|
'GET /stream': (req, res) => {
|
||||||
@@ -1630,6 +1848,7 @@ module.exports = {
|
|||||||
sseHead(res);
|
sseHead(res);
|
||||||
const state = current ? current.getState() : clearedState();
|
const state = current ? current.getState() : clearedState();
|
||||||
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
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();
|
if (!current) return void res.end();
|
||||||
const { streams } = current;
|
const { streams } = current;
|
||||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-row {
|
[data-module="pco-plan"] .pp-row {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.6em;
|
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-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; }
|
[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,
|
showDetail: true,
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
leadingZeros: false,
|
leadingZeros: false,
|
||||||
|
showLoops: false,
|
||||||
clock24h: true,
|
clock24h: true,
|
||||||
clockSeconds: true,
|
clockSeconds: true,
|
||||||
clockDate: false,
|
clockDate: false,
|
||||||
@@ -384,7 +385,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
if (keyOf(source, timer) !== solo) continue;
|
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
|
// The timer this tile was added for is not offered right now. Nothing is
|
||||||
@@ -398,7 +404,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
const items = [];
|
const items = [];
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
const key = keyOf(source, timer);
|
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.
|
// Every timer of a live source hidden on purpose: nothing to say.
|
||||||
if (!items.length && source.timers.length) continue;
|
if (!items.length && source.timers.length) continue;
|
||||||
@@ -666,7 +676,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.mono = null;
|
card.mono = null;
|
||||||
}
|
}
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
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);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
statusTone = timer.status?.tone || tone;
|
statusTone = timer.status?.tone || tone;
|
||||||
detailText = timer.detail || '';
|
detailText = timer.detail || '';
|
||||||
|
|||||||
@@ -191,9 +191,13 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
if (!tool.ready) {
|
if (!tool.ready) {
|
||||||
report({ error: tool.error, detail: tool.detail });
|
report({ error: tool.error, detail: tool.detail });
|
||||||
// A build in flight restarts us itself when it lands; anything else is
|
// onReady is only the FAST path (a successful build restarts us at
|
||||||
// config-shaped and should back off hard rather than spin.
|
// once). The slow retry must stay armed regardless: a failed build
|
||||||
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -161,6 +161,12 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
},
|
},
|
||||||
|
"showLoops": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Looping videos",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
"showHeadings": {
|
"showHeadings": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Source headings",
|
"label": "Source headings",
|
||||||
|
|||||||
@@ -255,6 +255,21 @@ function createProPresenterSource({ config, log }) {
|
|||||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||||
const phases = new Map();
|
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) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
if (password) built.searchParams.set('password', password);
|
if (password) built.searchParams.set('password', password);
|
||||||
@@ -390,6 +405,121 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return out;
|
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() {
|
function status() {
|
||||||
if (!configured) return 'error';
|
if (!configured) return 'error';
|
||||||
if (reachable) return 'ok';
|
if (reachable) return 'ok';
|
||||||
@@ -429,6 +559,9 @@ function createProPresenterSource({ config, log }) {
|
|||||||
const currentTimes = await requestJson('/v1/timers/current');
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
updatedAt = Date.now();
|
updatedAt = Date.now();
|
||||||
timers = mergeTimers(currentTimes, updatedAt);
|
timers = mergeTimers(currentTimes, updatedAt);
|
||||||
|
await pollVideo();
|
||||||
|
const video = videoTimer();
|
||||||
|
if (video) timers.push(video);
|
||||||
reachable = true;
|
reachable = true;
|
||||||
lastError = '';
|
lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -970,6 +1103,8 @@ const GEAR = {
|
|||||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
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. */
|
/** 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),
|
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||||
/** The time of day: plain digits, so only how they read. */
|
/** The time of day: plain digits, so only how they read. */
|
||||||
@@ -983,6 +1118,7 @@ function gearFor(source, timer) {
|
|||||||
if (source.kind === 'ltc') return GEAR.ltc;
|
if (source.kind === 'ltc') return GEAR.ltc;
|
||||||
if (source.kind === 'clock') return GEAR.clock;
|
if (source.kind === 'clock') return GEAR.clock;
|
||||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||||
|
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||||
return GEAR.timer;
|
return GEAR.timer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,7 +1327,8 @@ module.exports = {
|
|||||||
for (const t of src.timers) {
|
for (const t of src.timers) {
|
||||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||||
: src.kind === 'clock' ? 'The time of day 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.
|
// 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'] : [];
|
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 } : {}) });
|
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.9.0",
|
"version": "1.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -373,7 +373,18 @@ function mountModule(id) {
|
|||||||
const log = makeLog(id);
|
const log = makeLog(id);
|
||||||
try {
|
try {
|
||||||
const serverPath = path.join(man.dir, man.server);
|
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 mod = require(serverPath);
|
||||||
const config = effectiveConfig(id);
|
const config = effectiveConfig(id);
|
||||||
// What a module needs to reach the rest of ProdDash — another module's
|
// What a module needs to reach the rest of ProdDash — another module's
|
||||||
@@ -1060,13 +1071,22 @@ async function installModule(id) {
|
|||||||
if (download) {
|
if (download) {
|
||||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
const shipped = new Set();
|
||||||
for (const [rel, data] of remoteEntry.files) {
|
for (const [rel, data] of remoteEntry.files) {
|
||||||
if (!safeRelPath(rel)) continue;
|
if (!safeRelPath(rel)) continue;
|
||||||
|
shipped.add(rel);
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
fs.writeFileSync(target, data);
|
||||||
}
|
}
|
||||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
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);
|
unmountModule(id);
|
||||||
fs.rmSync(dest, { recursive: true, force: true });
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
fs.renameSync(staging, dest);
|
fs.renameSync(staging, dest);
|
||||||
@@ -1083,6 +1103,43 @@ async function installModule(id) {
|
|||||||
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
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
|
/** 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
|
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||||
later reinstall picks them up again. */
|
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') 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/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/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);
|
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) ─────────────────────── */
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
const cryptoMod = require('crypto');
|
const cryptoMod = require('crypto');
|
||||||
|
|||||||
Reference in New Issue
Block a user