Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8843339f11 |
@@ -346,15 +346,6 @@ 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
|
||||||
|
|||||||
+1
-142
@@ -34,7 +34,6 @@ 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 = `
|
||||||
@@ -494,133 +493,6 @@ 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 || {};
|
||||||
@@ -659,12 +531,11 @@ 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)}"]`);
|
||||||
if (row) keepInView(row);
|
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -739,7 +610,6 @@ 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();
|
||||||
@@ -751,7 +621,6 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -772,16 +641,6 @@ 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.8.0",
|
"version": "1.7.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,18 +382,6 @@
|
|||||||
"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": [
|
||||||
|
|||||||
+2
-221
@@ -9,10 +9,6 @@
|
|||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -37,13 +33,6 @@
|
|||||||
* 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
|
||||||
@@ -70,7 +59,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, similarity, MATCH_THRESHOLD } = require('./matching');
|
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||||
const {
|
const {
|
||||||
MINUTE,
|
MINUTE,
|
||||||
serviceTimes,
|
serviceTimes,
|
||||||
@@ -96,12 +85,6 @@ 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;
|
||||||
@@ -170,28 +153,6 @@ 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 {
|
||||||
@@ -1297,170 +1258,6 @@ 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);
|
||||||
@@ -1472,10 +1269,6 @@ 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1734,9 +1527,6 @@ 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(() => {
|
||||||
@@ -1769,7 +1559,6 @@ module.exports = {
|
|||||||
timerStreams,
|
timerStreams,
|
||||||
pco,
|
pco,
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
getAbleSet: () => abs,
|
|
||||||
learnPort,
|
learnPort,
|
||||||
refresh() {
|
refresh() {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
@@ -1791,10 +1580,7 @@ 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 */ }
|
||||||
@@ -1836,11 +1622,7 @@ 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, {
|
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||||
state: current ? current.getState() : clearedState(),
|
|
||||||
ableset: current ? current.getAbleSet() : clearedAbleSet(),
|
|
||||||
now: Date.now(),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /stream': (req, res) => {
|
'GET /stream': (req, res) => {
|
||||||
@@ -1848,7 +1630,6 @@ 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,7 +86,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[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;
|
||||||
@@ -590,70 +589,3 @@
|
|||||||
|
|
||||||
[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%));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,13 +10,23 @@
|
|||||||
* onUpdate roughly 4×/s:
|
* onUpdate roughly 4×/s:
|
||||||
*
|
*
|
||||||
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
* { 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
|
* Error strings are two-tier on purpose: `error` is what a dashboard tile
|
||||||
* tries one swiftc compile of the source next to it, and otherwise says to
|
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||||
* build it on any same-architecture Mac and copy it over. A dead child is
|
* for the admin health line and the log.
|
||||||
* restarted with backoff — fast for a crash, slow for config-shaped
|
*
|
||||||
* failures (unknown device, ambiguous name) that never fix themselves.
|
* 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
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
@@ -27,7 +37,7 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn, spawnSync } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
@@ -41,6 +51,100 @@ const RUNNING_WINDOW_MS = 400;
|
|||||||
/** stderr line the capture prints once the device is open. */
|
/** stderr line the capture prints once the device is open. */
|
||||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
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,
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
* onUpdate:(u:object)=>void}} opts
|
* onUpdate:(u:object)=>void}} opts
|
||||||
@@ -75,26 +179,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
return 'nosignal';
|
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() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const missing = ensureBinary();
|
const tool = ensureCaptureBinary({
|
||||||
if (missing) {
|
moduleDir,
|
||||||
report({ error: missing });
|
log,
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
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 });
|
||||||
|
// A build in flight restarts us itself when it lands; anything else is
|
||||||
|
// config-shaped and should back off hard rather than spin.
|
||||||
|
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +212,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
const channels = Number(m[2]);
|
const channels = Number(m[2]);
|
||||||
const rate = Number(m[3]);
|
const rate = Number(m[3]);
|
||||||
if (channel > channels) {
|
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 */ }
|
try { child.kill(); } catch { /* exiting anyway */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,17 +237,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
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.on('exit', (code) => {
|
||||||
child = null;
|
child = null;
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
// The capture's own failure text (unknown device, ambiguous name,
|
// 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 said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||||
const quickDeath = Date.now() - startedAt < 3000;
|
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);
|
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,4 +294,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLtcListener };
|
module.exports = { createLtcListener, ensureCaptureBinary };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.4.2",
|
"version": "1.5.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": [
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener } = require('./ltc-listener');
|
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
||||||
const { createTimerPhase } = require('./phase');
|
const { createTimerPhase } = require('./phase');
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
@@ -396,7 +396,16 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return lastError ? 'error' : 'connecting';
|
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 (!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'}`;
|
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
||||||
@@ -404,7 +413,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ status: status(), message: message(), timers, updatedAt });
|
emit({ status: status(), message: tileMessage(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollTick() {
|
async function pollTick() {
|
||||||
@@ -450,7 +459,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: status(), message: message() };
|
return { status: status(), message: adminMessage() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -464,7 +473,9 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
let emit = () => {};
|
let emit = () => {};
|
||||||
let listener = null;
|
let listener = null;
|
||||||
let stopped = false;
|
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;
|
let word = null;
|
||||||
|
|
||||||
function rateText() {
|
function rateText() {
|
||||||
@@ -516,8 +527,16 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
kind: 'ltc',
|
kind: 'ltc',
|
||||||
start(e) {
|
start(e) {
|
||||||
emit = 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) {
|
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();
|
publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -539,7 +558,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
listener = null;
|
listener = null;
|
||||||
},
|
},
|
||||||
health() {
|
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() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -588,7 +608,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
let silenceTimer = null;
|
let silenceTimer = null;
|
||||||
let backoff = BACKOFF_MIN_MS;
|
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 === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
||||||
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
||||||
return `unreachable — ${lastError}`;
|
return `unreachable — ${lastError}`;
|
||||||
@@ -596,7 +624,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ label, status: state, message: message(), timers, updatedAt });
|
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
function accept(raw) {
|
function accept(raw) {
|
||||||
@@ -774,7 +802,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
if (!stopped && !stream && via === 'poll') connectStream();
|
if (!stopped && !stream && via === 'poll') connectStream();
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: state, message: message() };
|
return { status: state, message: adminMessage() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1172,7 +1200,7 @@ module.exports = {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
routes({ log }) {
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
||||||
@@ -1195,7 +1223,15 @@ module.exports = {
|
|||||||
|
|
||||||
// The admin device picker asks this machine what it can hear. The
|
// The admin device picker asks this machine what it can hear. The
|
||||||
// options shape matches the admin select's optionsRoute contract.
|
// 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) => {
|
'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 bin = path.join(__dirname, 'ltc-capture');
|
||||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
let err = '';
|
let err = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user