Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d24a050193 | ||
|
|
d93162b4c4 | ||
|
|
93e9f714aa | ||
|
|
c9d7c02bd6 |
@@ -303,11 +303,9 @@ below. Nothing is installed without someone clicking.
|
|||||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||||
its item tags, plus tidy titles.
|
its item tags, plus tidy titles.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed), the **video countdown** (time left on what ProPresenter is
|
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
||||||
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
||||||
stay hidden) and LTC timecode as auto-sized cards. The + picker lists
|
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
every discovered timer (and the LTC card) as its own tile, or add "All
|
|
||||||
timers" and pick a selection via the tile's "Timers ▾" menu; overruns are
|
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
@@ -325,8 +323,9 @@ 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 two tiles in the + picker:
|
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
|
||||||
**Plan** (the order of service) and **Teams** (who is scheduled).
|
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
|
||||||
|
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,
|
||||||
@@ -357,6 +356,16 @@ 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 two tiles and this is the entry for both: the Teams
|
The module presents three tiles and this is the entry for all of them: the
|
||||||
tile (picker entry "teams") is built by teams-client.js, everything below
|
Teams tile (picker entry "teams") is built by teams-client.js and the Song
|
||||||
draws the Plan tile.
|
Key tile ("key") by key-client.js; everything below 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,6 +13,8 @@
|
|||||||
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>';
|
||||||
|
|
||||||
@@ -21,8 +23,9 @@ 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 }) {
|
||||||
// Tiles added as "Teams" carry that variant and have their own settings.
|
// Each picker entry carries its variant and its 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +370,8 @@ 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';
|
||||||
key.textContent = item.keyName;
|
// B♭, F♯m — the same notation the Song Key tile uses.
|
||||||
|
key.textContent = prettyKey(item.keyName);
|
||||||
titleLine.appendChild(key);
|
titleLine.appendChild(key);
|
||||||
}
|
}
|
||||||
if (on('showNowIndicator')) {
|
if (on('showNowIndicator')) {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/* 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.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* 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.8.0",
|
"version": "1.9.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service and team assignments from Planning Center Services",
|
"description": "Order of service, team assignments and the song key from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
],
|
],
|
||||||
@@ -621,6 +621,36 @@
|
|||||||
"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,7 +206,9 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-now-tag {
|
[data-module="pco-plan"] .pp-now-tag {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 0.05em 0.45em;
|
/* Optically centred like the section chips: uppercase has no descenders,
|
||||||
|
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);
|
||||||
@@ -635,25 +637,110 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .pp-abs-sec {
|
[data-module="pco-plan"] .pp-abs-sec {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 0.1em 0.5em;
|
/* Uppercase text has no descenders, so the font's descent allowance below
|
||||||
border-radius: 999px;
|
the baseline leaves the caps sitting high in the box — measured at 0.9px
|
||||||
border: 1px solid var(--border);
|
too much headroom. The padding is a touch shallower on top and deeper
|
||||||
|
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;
|
transition: color 0.2s ease, border-color 0.2s ease, background 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) 40%, transparent) var(--pp-sec-fill, 0%),
|
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
|
||||||
transparent var(--pp-sec-fill, 0%));
|
color-mix(in srgb, var(--text) 8%, var(--panel)) 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; }
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ const DEFAULTS = {
|
|||||||
showDetail: true,
|
showDetail: true,
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
leadingZeros: false,
|
leadingZeros: false,
|
||||||
showLoops: false,
|
|
||||||
clock24h: true,
|
clock24h: true,
|
||||||
clockSeconds: true,
|
clockSeconds: true,
|
||||||
clockDate: false,
|
clockDate: false,
|
||||||
@@ -385,12 +384,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
if (keyOf(source, timer) !== solo) continue;
|
if (keyOf(source, timer) !== solo) continue;
|
||||||
// A solo video tile with looping media hidden must not sit empty:
|
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||||
// it shows a quiet card instead of a countdown nobody should read.
|
|
||||||
const shown = timer.looping && pref('showLoops') !== true
|
|
||||||
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
|
|
||||||
: timer;
|
|
||||||
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The timer this tile was added for is not offered right now. Nothing is
|
// The timer this tile was added for is not offered right now. Nothing is
|
||||||
@@ -404,11 +398,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
const items = [];
|
const items = [];
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
const key = keyOf(source, timer);
|
const key = keyOf(source, timer);
|
||||||
if (hidden.has(key)) continue;
|
if (!hidden.has(key)) items.push({ key, source, timer });
|
||||||
// A looping background's time means nothing — the card sits out
|
|
||||||
// while a loop plays (per tile: the "Looping videos" switch).
|
|
||||||
if (timer.looping && pref('showLoops') !== true) continue;
|
|
||||||
items.push({ key, source, timer });
|
|
||||||
}
|
}
|
||||||
// Every timer of a live source hidden on purpose: nothing to say.
|
// Every timer of a live source hidden on purpose: nothing to say.
|
||||||
if (!items.length && source.timers.length) continue;
|
if (!items.length && source.timers.length) continue;
|
||||||
@@ -676,9 +666,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.mono = null;
|
card.mono = null;
|
||||||
}
|
}
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||||
// A shown loop never warns or overruns — it wraps at zero by design,
|
tone = toneFor(timer, value);
|
||||||
// and a background flashing red every pass would cry wolf all service.
|
|
||||||
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
|
|
||||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
statusTone = timer.status?.tone || tone;
|
statusTone = timer.status?.tone || tone;
|
||||||
detailText = timer.detail || '';
|
detailText = timer.detail || '';
|
||||||
|
|||||||
@@ -10,23 +10,13 @@
|
|||||||
* 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: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
|
* { error: '<what is wrong and how to fix it>' } not capturing
|
||||||
*
|
*
|
||||||
* Error strings are two-tier on purpose: `error` is what a dashboard tile
|
* The capture binary is per-machine (gitignored): if it is missing this
|
||||||
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
* tries one swiftc compile of the source next to it, and otherwise says to
|
||||||
* for the admin health line and the log.
|
* build it on any same-architecture Mac and copy it over. A dead child is
|
||||||
*
|
* restarted with backoff — fast for a crash, slow for config-shaped
|
||||||
* The capture binary is per-machine (gitignored), and a catalog install or
|
* failures (unknown device, ambiguous name) that never fix themselves.
|
||||||
* 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
|
||||||
@@ -37,7 +27,7 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn, spawnSync } = require('child_process');
|
||||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
@@ -51,100 +41,6 @@ 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
|
||||||
@@ -179,24 +75,25 @@ 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 tool = ensureCaptureBinary({
|
const missing = ensureBinary();
|
||||||
moduleDir,
|
if (missing) {
|
||||||
log,
|
report({ error: missing });
|
||||||
onReady() {
|
|
||||||
// The build this start() was waiting on landed — go now.
|
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!tool.ready) {
|
|
||||||
report({ error: tool.error, detail: tool.detail });
|
|
||||||
// onReady is only the FAST path (a successful build restarts us at
|
|
||||||
// once). The slow retry must stay armed regardless: a failed build
|
|
||||||
// never calls back, and a binary can also arrive from outside — scp'd
|
|
||||||
// in on a machine whose toolchain can't build (AB's broken CLT).
|
|
||||||
// Waiting on onReady alone deadlocked exactly that way in production:
|
|
||||||
// "Capture tool building…" forever, with a working binary on disk.
|
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -216,10 +113,7 @@ 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({
|
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -241,21 +135,17 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
report({ error: `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 DETAIL we have; the tile just
|
// permission hint) is the most useful thing we can show.
|
||||||
// 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({
|
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
||||||
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -298,4 +188,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLtcListener, ensureCaptureBinary };
|
module.exports = { createLtcListener };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.6.0",
|
"version": "1.4.2",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -161,12 +161,6 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
},
|
},
|
||||||
"showLoops": {
|
|
||||||
"type": "switch",
|
|
||||||
"label": "Looping videos",
|
|
||||||
"default": false,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showHeadings": {
|
"showHeadings": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Source headings",
|
"label": "Source headings",
|
||||||
|
|||||||
@@ -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, ensureCaptureBinary } = require('./ltc-listener');
|
const { createLtcListener } = require('./ltc-listener');
|
||||||
const { createTimerPhase } = require('./phase');
|
const { createTimerPhase } = require('./phase');
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
@@ -255,21 +255,6 @@ function createProPresenterSource({ config, log }) {
|
|||||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||||
const phases = new Map();
|
const phases = new Map();
|
||||||
|
|
||||||
/* The video countdown — time left on what the presentation layer is
|
|
||||||
playing, from the transport routes (verified live on ProPresenter 21.4):
|
|
||||||
GET /v1/transport/presentation/current → { is_playing, uuid, name,
|
|
||||||
artist, audio_only, duration } (no looping flag exists — inferred)
|
|
||||||
GET /v1/transport/presentation/time → bare seconds, sub-second
|
|
||||||
float — better than /v1/timer/video_countdown's whole-second string,
|
|
||||||
so no quantization phase lock is needed, only an anchor hold. */
|
|
||||||
let media = null; // { uuid, name, duration, playing } from the slow lane
|
|
||||||
let mediaAt = 0;
|
|
||||||
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
|
|
||||||
let videoPos = null; // playback position in seconds (float)
|
|
||||||
let videoAsOf = 0; // server ms when videoPos was measured
|
|
||||||
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
|
|
||||||
let loopingUuid = ''; // media seen wrapping — a looping background
|
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
if (password) built.searchParams.set('password', password);
|
if (password) built.searchParams.set('password', password);
|
||||||
@@ -405,137 +390,13 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Follow the presentation transport layer. Failures here only drop the
|
|
||||||
* video card, never the whole source — the timers must survive a
|
|
||||||
* ProPresenter build with transport quirks.
|
|
||||||
*/
|
|
||||||
async function pollVideo() {
|
|
||||||
if (tick % SLOW_EVERY === 1) {
|
|
||||||
try {
|
|
||||||
const raw = await requestJson('/v1/transport/presentation/current');
|
|
||||||
const uuid = String(raw?.uuid || '');
|
|
||||||
if (!media || media.uuid !== uuid) {
|
|
||||||
if (media && media.uuid !== uuid) loopingUuid = '';
|
|
||||||
videoPos = null;
|
|
||||||
videoAnchor = null;
|
|
||||||
}
|
|
||||||
media = uuid ? {
|
|
||||||
uuid,
|
|
||||||
name: String(raw.name || ''),
|
|
||||||
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
|
|
||||||
playing: Boolean(raw.is_playing),
|
|
||||||
} : null;
|
|
||||||
mediaAt = Date.now();
|
|
||||||
transportOk = true;
|
|
||||||
} catch (err) {
|
|
||||||
media = null; // the slow lane retries in ~2 s
|
|
||||||
if (err && err.status === 404) transportOk = false; // no transport route on this build
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!media) {
|
|
||||||
videoPos = null;
|
|
||||||
videoAnchor = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const pos = Number(await requestJson('/v1/transport/presentation/time'));
|
|
||||||
if (Number.isFinite(pos) && pos >= 0) {
|
|
||||||
// Same media, position jumped back by more than a stutter: it
|
|
||||||
// wrapped — a looping background. Sticks until the media changes.
|
|
||||||
// (A video manually re-triggered from the start looks the same and
|
|
||||||
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
|
|
||||||
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
|
|
||||||
videoPos = pos;
|
|
||||||
videoAsOf = Date.now();
|
|
||||||
} else {
|
|
||||||
videoPos = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
videoPos = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The video countdown as one synthetic timer card (id 'video'). The card
|
|
||||||
persists as an idle "—" between videos — a wall tile must not appear
|
|
||||||
and vanish with the media — and is only absent entirely on a
|
|
||||||
ProPresenter without transport routes. */
|
|
||||||
function videoTimer() {
|
|
||||||
if (transportOk !== true) return null;
|
|
||||||
if (!media) {
|
|
||||||
return {
|
|
||||||
id: 'video',
|
|
||||||
label: 'Video',
|
|
||||||
kind: 'countdown',
|
|
||||||
state: 'idle',
|
|
||||||
asOf: mediaAt || updatedAt,
|
|
||||||
startedAt: null,
|
|
||||||
elapsedMs: null,
|
|
||||||
remainingMs: null,
|
|
||||||
targetMs: null,
|
|
||||||
looping: false,
|
|
||||||
status: null,
|
|
||||||
detail: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const playing = media.playing;
|
|
||||||
const dur = media.duration > 0 ? media.duration * 1000 : null;
|
|
||||||
let remainingMs = null;
|
|
||||||
let asOf = videoAsOf || mediaAt;
|
|
||||||
if (videoPos !== null && dur !== null) {
|
|
||||||
remainingMs = Math.max(0, dur - videoPos * 1000);
|
|
||||||
if (playing) {
|
|
||||||
// Hold the published anchor while it still predicts the measured
|
|
||||||
// position: the tiles extrapolate locally, and a fresh anchor per
|
|
||||||
// poll would broadcast a frame every 500 ms for nothing.
|
|
||||||
const p = videoAnchor;
|
|
||||||
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
|
|
||||||
if (p && Math.abs(predicted - remainingMs) < 120) {
|
|
||||||
remainingMs = p.remainingMs;
|
|
||||||
asOf = p.asOf;
|
|
||||||
} else {
|
|
||||||
videoAnchor = { remainingMs, asOf };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoAnchor = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoAnchor = null;
|
|
||||||
}
|
|
||||||
const looping = media.uuid === loopingUuid;
|
|
||||||
return {
|
|
||||||
id: 'video',
|
|
||||||
label: 'Video',
|
|
||||||
kind: 'countdown',
|
|
||||||
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
|
|
||||||
asOf,
|
|
||||||
startedAt: null,
|
|
||||||
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
|
|
||||||
remainingMs,
|
|
||||||
targetMs: dur,
|
|
||||||
looping,
|
|
||||||
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
|
|
||||||
// The media name, without the file extension nobody reads on a wall.
|
|
||||||
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function status() {
|
function status() {
|
||||||
if (!configured) return 'error';
|
if (!configured) return 'error';
|
||||||
if (reachable) return 'ok';
|
if (reachable) return 'ok';
|
||||||
return lastError ? 'error' : 'connecting';
|
return lastError ? 'error' : 'connecting';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short, for the tiles — a dashboard warns, the admin page explains.
|
function message() {
|
||||||
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}…`;
|
||||||
@@ -543,7 +404,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ status: status(), message: tileMessage(), timers, updatedAt });
|
emit({ status: status(), message: message(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollTick() {
|
async function pollTick() {
|
||||||
@@ -559,9 +420,6 @@ function createProPresenterSource({ config, log }) {
|
|||||||
const currentTimes = await requestJson('/v1/timers/current');
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
updatedAt = Date.now();
|
updatedAt = Date.now();
|
||||||
timers = mergeTimers(currentTimes, updatedAt);
|
timers = mergeTimers(currentTimes, updatedAt);
|
||||||
await pollVideo();
|
|
||||||
const video = videoTimer();
|
|
||||||
if (video) timers.push(video);
|
|
||||||
reachable = true;
|
reachable = true;
|
||||||
lastError = '';
|
lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -592,7 +450,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: status(), message: adminMessage() };
|
return { status: status(), message: message() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -606,9 +464,7 @@ 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 } |
|
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
|
||||||
{ error, detail } (error = short tile text, detail = the admin/log
|
|
||||||
story) | null while starting. */
|
|
||||||
let word = null;
|
let word = null;
|
||||||
|
|
||||||
function rateText() {
|
function rateText() {
|
||||||
@@ -660,16 +516,8 @@ 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 = {
|
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
|
||||||
error: 'Not configured — see Admin',
|
|
||||||
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
|
|
||||||
};
|
|
||||||
publish();
|
publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -691,8 +539,7 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
listener = null;
|
listener = null;
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
// The admin page gets the full story; the tiles got the short one.
|
return { status: status(), message: message() };
|
||||||
return { status: status(), message: (word && word.error && word.detail) || message() };
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -741,15 +588,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
let silenceTimer = null;
|
let silenceTimer = null;
|
||||||
let backoff = BACKOFF_MIN_MS;
|
let backoff = BACKOFF_MIN_MS;
|
||||||
|
|
||||||
/** Short, for the tiles; the lastError detail belongs to the admin line.
|
function message() {
|
||||||
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}`;
|
||||||
@@ -757,7 +596,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
|
emit({ label, status: state, message: message(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
function accept(raw) {
|
function accept(raw) {
|
||||||
@@ -935,7 +774,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: adminMessage() };
|
return { status: state, message: message() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1103,8 +942,6 @@ const GEAR = {
|
|||||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
||||||
/** The video countdown: a timer card, plus whether looping media counts. */
|
|
||||||
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
|
|
||||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
||||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||||
/** The time of day: plain digits, so only how they read. */
|
/** The time of day: plain digits, so only how they read. */
|
||||||
@@ -1118,7 +955,6 @@ function gearFor(source, timer) {
|
|||||||
if (source.kind === 'ltc') return GEAR.ltc;
|
if (source.kind === 'ltc') return GEAR.ltc;
|
||||||
if (source.kind === 'clock') return GEAR.clock;
|
if (source.kind === 'clock') return GEAR.clock;
|
||||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||||
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
|
||||||
return GEAR.timer;
|
return GEAR.timer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1327,8 +1163,7 @@ module.exports = {
|
|||||||
for (const t of src.timers) {
|
for (const t of src.timers) {
|
||||||
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
|
||||||
: src.kind === 'clock' ? 'The time of day on its own'
|
: src.kind === 'clock' ? 'The time of day on its own'
|
||||||
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
|
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
||||||
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
|
|
||||||
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
|
||||||
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
|
||||||
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
|
||||||
@@ -1337,7 +1172,7 @@ module.exports = {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
routes({ log }) {
|
routes() {
|
||||||
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() });
|
||||||
@@ -1360,15 +1195,7 @@ 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.2",
|
"version": "1.9.0",
|
||||||
"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,18 +373,7 @@ 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);
|
||||||
// Drop the module's code from the require cache so every (re)mount runs
|
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
||||||
// 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
|
||||||
@@ -1071,22 +1060,13 @@ async function installModule(id) {
|
|||||||
if (download) {
|
if (download) {
|
||||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
const shipped = new Set();
|
|
||||||
for (const [rel, data] of remoteEntry.files) {
|
for (const [rel, data] of remoteEntry.files) {
|
||||||
if (!safeRelPath(rel)) continue;
|
if (!safeRelPath(rel)) continue;
|
||||||
shipped.add(rel);
|
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
fs.writeFileSync(target, data);
|
||||||
}
|
}
|
||||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
// Per-machine executables — a capture tool compiled here, or copied in
|
|
||||||
// by hand on a machine whose toolchain can't build it — are gitignored,
|
|
||||||
// so the repo never ships them. Replacing the folder must not destroy
|
|
||||||
// them: carry executables the download doesn't include over from the
|
|
||||||
// copy being replaced (or from the app's bundled copy on a first
|
|
||||||
// install), or "update" quietly kills a working module.
|
|
||||||
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
|
|
||||||
unmountModule(id);
|
unmountModule(id);
|
||||||
fs.rmSync(dest, { recursive: true, force: true });
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
fs.renameSync(staging, dest);
|
fs.renameSync(staging, dest);
|
||||||
@@ -1103,43 +1083,6 @@ async function installModule(id) {
|
|||||||
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy executable files (any execute bit) that `shipped` does not include
|
|
||||||
* from the first existing source dir into `staging`, keeping their relative
|
|
||||||
* path and mode. Executables only, on purpose: they are the per-machine
|
|
||||||
* build artifacts (gitignored, so never in a download), while stray text
|
|
||||||
* files from an old version — code an update deliberately removed — must
|
|
||||||
* NOT be resurrected.
|
|
||||||
*/
|
|
||||||
function carryOverExecutables(sourceDirs, staging, shipped) {
|
|
||||||
const from = sourceDirs.find((d) => {
|
|
||||||
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
|
||||||
});
|
|
||||||
if (!from) return;
|
|
||||||
const walk = (rel) => {
|
|
||||||
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
|
|
||||||
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
||||||
if (e.isDirectory()) {
|
|
||||||
walk(relPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(path.join(from, relPath));
|
|
||||||
if (!(stat.mode & 0o111)) continue;
|
|
||||||
const target = path.join(staging, relPath);
|
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
||||||
fs.copyFileSync(path.join(from, relPath), target);
|
|
||||||
fs.chmodSync(target, stat.mode);
|
|
||||||
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||||
off and hidden (its folder belongs to the app). Settings are kept so a
|
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||||
later reinstall picks them up again. */
|
later reinstall picks them up again. */
|
||||||
|
|||||||
@@ -152,39 +152,10 @@ const server = http.createServer((req, res) => {
|
|||||||
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||||
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
|
|
||||||
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
|
|
||||||
|
|
||||||
sendJson(res, { error: 'not found' }, 404);
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── transport (video countdown): a 4-minute cycle ─────────────────
|
|
||||||
0–60 s "Sermon Bumper.mp4" (3:00) playing
|
|
||||||
60–75 s the same bumper paused at 1:00
|
|
||||||
75–90 s playing again
|
|
||||||
90–120 s nothing loaded
|
|
||||||
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
|
|
||||||
must infer looping from the position jumping back.
|
|
||||||
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
|
|
||||||
name, artist, audio_only, duration }, `time` = bare seconds (float). */
|
|
||||||
function transport() {
|
|
||||||
const phase = ((Date.now() - startedAt) / 1000) % 240;
|
|
||||||
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
|
|
||||||
if (phase < 90) {
|
|
||||||
const playing = phase < 60 || phase >= 75;
|
|
||||||
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
|
|
||||||
return {
|
|
||||||
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
|
|
||||||
time: Math.round(pos * 10) / 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (phase < 120) return none;
|
|
||||||
return {
|
|
||||||
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
|
|
||||||
time: Math.round(((phase - 120) % 15) * 10) / 10,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
const cryptoMod = require('crypto');
|
const cryptoMod = require('crypto');
|
||||||
|
|||||||
Reference in New Issue
Block a user