Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076e583daf | ||
|
|
21519dd3da | ||
|
|
8843339f11 | ||
|
|
4ed22b361b |
@@ -323,9 +323,8 @@ below. Nothing is installed without someone clicking.
|
|||||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||||
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
|
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
||||||
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
|
**Plan** (the order of service) and **Teams** (who is scheduled).
|
||||||
Key** (the key of the item on screen).
|
|
||||||
|
|
||||||
**Plan** shows every
|
**Plan** shows every
|
||||||
plan item with its scheduled start time, length, type, song key,
|
plan item with its scheduled start time, length, type, song key,
|
||||||
@@ -356,16 +355,6 @@ below. Nothing is installed without someone clicking.
|
|||||||
is switched off, or it has no host — nothing to turn off and nothing to
|
is switched off, or it has no host — nothing to turn off and nothing to
|
||||||
explain.
|
explain.
|
||||||
|
|
||||||
**Song Key** is one value, as large as the tile allows: the key of the item
|
|
||||||
ProPresenter has live, with the song's name and the next key under it — both
|
|
||||||
switches, as is the colour. When the feed that picks the item is gone the key
|
|
||||||
stays up, faded, and the tile's dot says so rather than pretending to be
|
|
||||||
current.
|
|
||||||
|
|
||||||
Keys are written as notes wherever the module shows one: Planning Center's
|
|
||||||
`Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the
|
|
||||||
Song Key tile alike.
|
|
||||||
|
|
||||||
**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,8 +1,8 @@
|
|||||||
/* PCO Plan — client part.
|
/* PCO Plan — client part.
|
||||||
|
|
||||||
The module presents three tiles and this is the entry for all of them: the
|
The module presents two tiles and this is the entry for both: the Teams
|
||||||
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
tile (picker entry "teams") is built by teams-client.js, everything below
|
||||||
Key tile ("key") by key-client.js; everything below draws the Plan tile.
|
draws the Plan tile.
|
||||||
|
|
||||||
The server part reads Planning Center and (optionally) follows the
|
The server part reads Planning Center and (optionally) follows the
|
||||||
ProPresenter module's live item; this tile only renders the plan it is
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
@@ -13,8 +13,6 @@
|
|||||||
the connection settings. */
|
the connection settings. */
|
||||||
|
|
||||||
import createTeams from "./teams-client.js";
|
import createTeams from "./teams-client.js";
|
||||||
import createKey from "./key-client.js";
|
|
||||||
import { prettyKey } from "./key-notation.js";
|
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
|
|
||||||
@@ -23,9 +21,8 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
|||||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
// Each picker entry carries its variant and its own settings.
|
// Tiles added as "Teams" carry that variant and have their own settings.
|
||||||
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
||||||
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
|
|
||||||
return createPlan({ root, moduleApi });
|
return createPlan({ root, moduleApi });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +367,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
if (on('showKey') && item.keyName) {
|
if (on('showKey') && item.keyName) {
|
||||||
const key = document.createElement('span');
|
const key = document.createElement('span');
|
||||||
key.className = 'pp-key';
|
key.className = 'pp-key';
|
||||||
// B♭, F♯m — the same notation the Song Key tile uses.
|
key.textContent = item.keyName;
|
||||||
key.textContent = prettyKey(item.keyName);
|
|
||||||
titleLine.appendChild(key);
|
titleLine.appendChild(key);
|
||||||
}
|
}
|
||||||
if (on('showNowIndicator')) {
|
if (on('showNowIndicator')) {
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/* PCO Plan — the Song Key tile.
|
|
||||||
|
|
||||||
One thing, big: the key of the plan item on screen. It reads the same
|
|
||||||
streamed state the Plan tile does, so it needs no connection of its own;
|
|
||||||
which item is current comes from the ProPresenter module, as ever. */
|
|
||||||
|
|
||||||
import { parseKey, prettyKey } from "./key-notation.js";
|
|
||||||
|
|
||||||
export default function createKey({ root, moduleApi }) {
|
|
||||||
let state = null;
|
|
||||||
let stream = null;
|
|
||||||
let feedOffline = false;
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="pk-wrap">
|
|
||||||
<div class="pk-key"></div>
|
|
||||||
<div class="pk-name"></div>
|
|
||||||
<div class="pk-next"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const wrap = root.querySelector('.pk-wrap');
|
|
||||||
const keyEl = root.querySelector('.pk-key');
|
|
||||||
const nameEl = root.querySelector('.pk-name');
|
|
||||||
const nextEl = root.querySelector('.pk-next');
|
|
||||||
|
|
||||||
const cfg = () => moduleApi.instanceSettings || {};
|
|
||||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
|
||||||
|
|
||||||
/* ── the item on screen, and the one after it ───────────────────── */
|
|
||||||
|
|
||||||
function items() {
|
|
||||||
return Array.isArray(state?.items) ? state.items : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentItem() {
|
|
||||||
const id = state?.live?.following ? state.live.currentItemId : '';
|
|
||||||
return id ? items().find((it) => it.id === id) || null : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The next item that actually has a key — a talk between two songs is skipped. */
|
|
||||||
function nextKeyed(after) {
|
|
||||||
const list = items();
|
|
||||||
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
|
|
||||||
if (from <= 0) return null;
|
|
||||||
for (let i = from; i < list.length; i += 1) {
|
|
||||||
if (list[i].keyName) return list[i];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleOf(item) {
|
|
||||||
return item ? (item.song?.title || item.title || '') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── render ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function renderStatus() {
|
|
||||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
|
||||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
|
||||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
|
||||||
if (!state.reachable) {
|
|
||||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
|
||||||
}
|
|
||||||
const item = currentItem();
|
|
||||||
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
|
|
||||||
// The key on screen stays — the band still needs it — but a tile must
|
|
||||||
// never look confidently live while the feed that chose it is gone.
|
|
||||||
if (!state.live.available) {
|
|
||||||
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
|
|
||||||
}
|
|
||||||
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
|
|
||||||
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
|
|
||||||
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
|
|
||||||
* better with the accidental raised and the minor kept small.
|
|
||||||
*/
|
|
||||||
function drawKey(text) {
|
|
||||||
keyEl.innerHTML = '';
|
|
||||||
const parts = parseKey(text);
|
|
||||||
if (!parts) {
|
|
||||||
keyEl.textContent = text;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
keyEl.appendChild(document.createTextNode(parts.note));
|
|
||||||
if (parts.accidental) {
|
|
||||||
const acc = document.createElement('span');
|
|
||||||
acc.className = 'pk-accidental';
|
|
||||||
acc.textContent = parts.accidental;
|
|
||||||
keyEl.appendChild(acc);
|
|
||||||
}
|
|
||||||
if (parts.quality) {
|
|
||||||
const tail = document.createElement('span');
|
|
||||||
tail.className = 'pk-quality';
|
|
||||||
tail.textContent = parts.quality;
|
|
||||||
keyEl.appendChild(tail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
|
||||||
const live = state?.live;
|
|
||||||
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
|
|
||||||
const color = String(cfg().keyColor || '').trim();
|
|
||||||
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
|
|
||||||
renderStatus();
|
|
||||||
|
|
||||||
const item = currentItem();
|
|
||||||
const key = item?.keyName || '';
|
|
||||||
wrap.classList.toggle('has-key', Boolean(key));
|
|
||||||
|
|
||||||
if (!state || !state.configured || !state.serviceTypeSet) {
|
|
||||||
drawKey('—');
|
|
||||||
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
|
|
||||||
nextEl.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!item) {
|
|
||||||
drawKey('—');
|
|
||||||
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
|
|
||||||
nextEl.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawKey(key || '—');
|
|
||||||
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
|
|
||||||
nameEl.hidden = !nameEl.textContent;
|
|
||||||
|
|
||||||
const next = on('showNextKey') ? nextKeyed(item) : null;
|
|
||||||
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
|
|
||||||
nextEl.hidden = !nextEl.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── live feed ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function accept(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
|
||||||
state = payload.state;
|
|
||||||
feedOffline = false;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
stream = moduleApi.sse('/stream', {
|
|
||||||
open() { feedOffline = false; render(); },
|
|
||||||
error() { feedOffline = true; render(); },
|
|
||||||
events: {
|
|
||||||
state(ev) {
|
|
||||||
try {
|
|
||||||
accept(JSON.parse(ev.data));
|
|
||||||
} catch { /* a bad frame is not fatal */ }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
return {
|
|
||||||
start() {
|
|
||||||
moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
moduleApi.fetch('/state')
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((body) => { if (body && !state) accept(body); })
|
|
||||||
.catch(() => { /* the stream will cover it */ });
|
|
||||||
connect();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
stream?.close();
|
|
||||||
stream = null;
|
|
||||||
root.innerHTML = '';
|
|
||||||
},
|
|
||||||
onConfigChange() {
|
|
||||||
// Admin holds the connection only; the server re-inits and re-sends.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/* PCO Plan — how a song key is written.
|
|
||||||
|
|
||||||
Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this
|
|
||||||
module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge
|
|
||||||
on an item and the Song Key tile all agree. Shared rather than repeated
|
|
||||||
because the day one of them changes, they all should. */
|
|
||||||
|
|
||||||
/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */
|
|
||||||
export function parseKey(raw) {
|
|
||||||
const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim());
|
|
||||||
if (!match) return null;
|
|
||||||
const [, note, accidental, quality] = match;
|
|
||||||
return {
|
|
||||||
note,
|
|
||||||
accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental,
|
|
||||||
quality: quality.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same thing as one string, for a badge or a caption. */
|
|
||||||
export function prettyKey(raw) {
|
|
||||||
const parts = parseKey(raw);
|
|
||||||
if (!parts) return String(raw || '').trim();
|
|
||||||
return `${parts.note}${parts.accidental}${parts.quality}`;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.9.0",
|
"version": "1.8.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
"description": "Order of service and team assignments from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
],
|
],
|
||||||
@@ -621,36 +621,6 @@
|
|||||||
"columns": 2
|
"columns": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "key",
|
|
||||||
"name": "Song Key",
|
|
||||||
"description": "The key of the item on screen, big",
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 2,
|
|
||||||
"h": 2
|
|
||||||
},
|
|
||||||
"minSize": {
|
|
||||||
"w": 1,
|
|
||||||
"h": 1
|
|
||||||
},
|
|
||||||
"instanceSchema": {
|
|
||||||
"keyColor": {
|
|
||||||
"type": "color",
|
|
||||||
"label": "Key",
|
|
||||||
"default": "#4ea1ff"
|
|
||||||
},
|
|
||||||
"showSongName": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Song name",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"showNextKey": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Next key",
|
|
||||||
"default": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,9 +206,7 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-now-tag {
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
display: none;
|
display: none;
|
||||||
/* Optically centred like the section chips: uppercase has no descenders,
|
padding: 0.05em 0.45em;
|
||||||
so the capitals sat 0.59px low in the pill. */
|
|
||||||
padding: 0.018em 0.45em 0.082em;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--pp-current);
|
background: var(--pp-current);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
@@ -637,110 +635,25 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-abs-sec {
|
[data-module="pco-plan"] .pp-abs-sec {
|
||||||
flex: none;
|
flex: none;
|
||||||
/* Uppercase text has no descenders, so the font's descent allowance below
|
padding: 0.1em 0.5em;
|
||||||
the baseline leaves the caps sitting high in the box — measured at 0.9px
|
border-radius: 999px;
|
||||||
too much headroom. The padding is a touch shallower on top and deeper
|
border: 1px solid var(--border);
|
||||||
below to centre the letters themselves; in em, so it holds at any text
|
|
||||||
size. */
|
|
||||||
padding: 0.095em 0.55em 0.185em;
|
|
||||||
/* The same rounded square, tinted ground and transparent border the AbleSet
|
|
||||||
module gives its own section pills, so the two read as one family. */
|
|
||||||
border-radius: 0.35em;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: color-mix(in srgb, var(--text) 8%, var(--panel));
|
|
||||||
font-size: 0.72em;
|
font-size: 0.72em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
transition: color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
[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. */
|
/* The section now playing fills left to right as it goes, like the row. */
|
||||||
/* The section playing keeps its own ground and fills across it, so the
|
|
||||||
progress reads against the chip rather than against the row behind it. */
|
|
||||||
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg,
|
linear-gradient(90deg,
|
||||||
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
|
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
|
||||||
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
|
transparent var(--pp-sec-fill, 0%));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── the Song Key tile ────────────────────────────────────────────────
|
|
||||||
One value, as large as the tile allows. Sizes come from the tile itself
|
|
||||||
(container units), like the AbleSet module's single readouts, so a small
|
|
||||||
tile and a wall-sized one both read properly. */
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-wrap {
|
|
||||||
--pk-color: #4ea1ff;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
container-type: size;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.2em;
|
|
||||||
padding: 4%;
|
|
||||||
text-align: center;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-key {
|
|
||||||
font-size: clamp(18px, 46cqmin, 260px);
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
|
|
||||||
|
|
||||||
/* A key is a note, but it has to read as one word at a glance from the back
|
|
||||||
of a room: the accidental and the quality sit on the same line as the
|
|
||||||
letter, only smaller. Raising the accidental looked like two stacked
|
|
||||||
characters at tile sizes. */
|
|
||||||
[data-module="pco-plan"] .pk-accidental {
|
|
||||||
font-size: 0.68em;
|
|
||||||
margin-left: 0.01em;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-quality {
|
|
||||||
font-size: 0.5em;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-left: 0.05em;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-name {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: clamp(9px, 9cqmin, 30px);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-next {
|
|
||||||
max-width: 100%;
|
|
||||||
font-size: clamp(8px, 6.5cqmin, 20px);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
|
|
||||||
/* The feed that picks the item is gone: the key stays readable, but faded,
|
|
||||||
so nobody trusts it as current without looking at the status dot. */
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
|
|
||||||
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
|
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user