Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3967af110 | ||
|
|
92b98b6579 | ||
|
|
58f3e5010a | ||
|
|
7cc19ef696 | ||
|
|
86a3270f1c | ||
|
|
e9d6edc2c8 | ||
|
|
b3b7b29007 | ||
|
|
62c34fe80c | ||
|
|
4c6a2b8f76 | ||
|
|
b0defbd56b | ||
|
|
914ff7795b | ||
|
|
076e583daf | ||
|
|
21519dd3da | ||
|
|
4ed22b361b | ||
|
|
aec9c47ed4 | ||
|
|
9313c9e42a |
@@ -303,9 +303,11 @@ below. Nothing is installed without someone clicking.
|
|||||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||||
its item tags, plus tidy titles.
|
its item tags, plus tidy titles.
|
||||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||||
elapsed) and LTC timecode as auto-sized cards. The + picker lists every
|
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||||
discovered timer (and the LTC card) as its own tile, or add "All timers"
|
playing, with a per-tile "Looping videos" switch so looping backgrounds
|
||||||
and pick a selection via the tile's "Timers ▾" menu; overruns are
|
stay hidden) and LTC timecode as auto-sized cards. The + picker lists
|
||||||
|
every discovered timer (and the LTC card) as its own tile, or add "All
|
||||||
|
timers" and pick a selection via the tile's "Timers ▾" menu; overruns are
|
||||||
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
three groups: **ProPresenter** (endpoint host : port, API password),
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
@@ -346,6 +348,15 @@ below. Nothing is installed without someone clicking.
|
|||||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||||
show/hide checklist for every detail.
|
show/hide checklist for every detail.
|
||||||
|
|
||||||
|
With an **AbleSet** module installed and configured, the Plan tile also
|
||||||
|
follows the band: while AbleSet plays a song that is the item on screen,
|
||||||
|
that item's background fills as the song goes, and its sections slide past
|
||||||
|
in a strip at the bottom right of the item, the one playing filling in
|
||||||
|
turn. Both are switches in the tile's gear (**Song progress**, **Song
|
||||||
|
sections**), and both are simply absent when there is no AbleSet module, it
|
||||||
|
is switched off, or it has no host — nothing to turn off and nothing to
|
||||||
|
explain.
|
||||||
|
|
||||||
**Teams** shows the same plan's team and position assignments — each team
|
**Teams** shows the same plan's team and position assignments — each team
|
||||||
as a band of its people, with their photo (fetched through ProdDash, never
|
as a band of its people, with their photo (fetched through ProdDash, never
|
||||||
by the browser; initials when Planning Center has none) and a dot for
|
by the browser; initials when Planning Center has none) and a dot for
|
||||||
|
|||||||
@@ -391,7 +391,8 @@ const menu = moduleApi.header.addMenu({
|
|||||||
build(menuEl) {
|
build(menuEl) {
|
||||||
// called on every open with an emptied menu element — fill it with
|
// called on every open with an emptied menu element — fill it with
|
||||||
// buttons (give them your scoped classes; the container is styled and
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
// positioned by the shell, hosted on the body under an element carrying
|
||||||
|
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||||
|
|||||||
+142
-1
@@ -34,6 +34,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
let ticker = null;
|
let ticker = null;
|
||||||
let lastCurrentId = null;
|
let lastCurrentId = null;
|
||||||
let lastListKey = '';
|
let lastListKey = '';
|
||||||
|
let absView = null; // what the server says AbleSet is doing, if anything
|
||||||
let userScrolledAt = 0;
|
let userScrolledAt = 0;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -493,6 +494,133 @@ function createPlan({ root, moduleApi }) {
|
|||||||
renderLiveMarks();
|
renderLiveMarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── AbleSet: the song playing under the current item ──────────────
|
||||||
|
Everything here draws only when the server says there is an AbleSet
|
||||||
|
module, it is configured and reachable, and the song it is playing is
|
||||||
|
the plan item on screen. Any of those missing and the tile looks
|
||||||
|
exactly as it did before AbleSet existed. */
|
||||||
|
|
||||||
|
/** The playhead now, in beats, carried forward from the last packet. */
|
||||||
|
function absBeatsNow() {
|
||||||
|
if (!absView || absView.beats == null) return null;
|
||||||
|
if (!absView.playing || !absView.tempo) return absView.beats;
|
||||||
|
const since = Math.max(0, serverNow() - absView.at) / 1000;
|
||||||
|
return absView.beats + since * (absView.tempo / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absFraction(part) {
|
||||||
|
const beats = absBeatsNow();
|
||||||
|
if (beats == null || !part || part.start == null || part.end == null) return null;
|
||||||
|
const span = part.end - part.start;
|
||||||
|
if (!(span > 0)) return null;
|
||||||
|
return Math.max(0, Math.min(1, (beats - part.start) / span));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row AbleSet's song belongs to — only ever the current one. */
|
||||||
|
function absRow() {
|
||||||
|
const a = absView;
|
||||||
|
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
|
||||||
|
if (a.itemId !== lastCurrentId) return null;
|
||||||
|
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the playing section visible in the strip — this strip, nothing else. */
|
||||||
|
function absScrollSections(strip) {
|
||||||
|
const active = strip.querySelector('.pp-abs-sec.is-now');
|
||||||
|
if (!active) return;
|
||||||
|
const box = strip.getBoundingClientRect();
|
||||||
|
const rect = active.getBoundingClientRect();
|
||||||
|
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
|
||||||
|
if (Math.abs(delta) < 1) return;
|
||||||
|
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAbleSet() {
|
||||||
|
const row = absRow();
|
||||||
|
|
||||||
|
// Anything left on a row that is no longer the one goes.
|
||||||
|
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
|
||||||
|
if (!row || !row.contains(stale)) stale.remove();
|
||||||
|
}
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
if (on('absProgress')) {
|
||||||
|
const fraction = absFraction(absView.song);
|
||||||
|
if (fraction == null) {
|
||||||
|
row.querySelector('.pp-fill')?.remove();
|
||||||
|
} else {
|
||||||
|
let fill = row.querySelector('.pp-fill');
|
||||||
|
if (!fill) {
|
||||||
|
fill = document.createElement('div');
|
||||||
|
fill.className = 'pp-fill';
|
||||||
|
row.prepend(fill); // behind the row's content, which the stylesheet lifts
|
||||||
|
}
|
||||||
|
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.querySelector('.pp-fill')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (on('absSections') && absView.sections.length) {
|
||||||
|
const main = row.querySelector('.pp-col-main');
|
||||||
|
let strip = row.querySelector('.pp-abs-sections');
|
||||||
|
if (!strip && main) {
|
||||||
|
strip = document.createElement('div');
|
||||||
|
strip.className = 'pp-abs-sections';
|
||||||
|
main.appendChild(strip);
|
||||||
|
}
|
||||||
|
if (strip) {
|
||||||
|
// The chips are rebuilt only when the song's sections change. Moving
|
||||||
|
// on to the next section just moves the classes: rebuilding would
|
||||||
|
// throw the strip's scroll away thirteen times a song.
|
||||||
|
const key = absView.sections.map((s) => s.name).join('\u0000');
|
||||||
|
if (strip.dataset.key !== key) {
|
||||||
|
strip.dataset.key = key;
|
||||||
|
strip.dataset.now = '';
|
||||||
|
strip.innerHTML = '';
|
||||||
|
for (const section of absView.sections) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'pp-abs-sec';
|
||||||
|
chip.textContent = section.name;
|
||||||
|
strip.appendChild(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = absView.section?.index ?? -1;
|
||||||
|
if (strip.dataset.now !== String(now)) {
|
||||||
|
strip.dataset.now = String(now);
|
||||||
|
[...strip.children].forEach((chip, i) => {
|
||||||
|
chip.classList.toggle('is-now', i === now);
|
||||||
|
chip.classList.toggle('is-done', i < now);
|
||||||
|
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => absScrollSections(strip));
|
||||||
|
}
|
||||||
|
const active = strip.children[now];
|
||||||
|
if (active) {
|
||||||
|
const fraction = absFraction(absView.section);
|
||||||
|
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.querySelector('.pp-abs-sections')?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
|
||||||
|
* scroll every ancestor that can scroll with it, which on a dashboard taller
|
||||||
|
* than the window drags the whole board; moving `listEl.scrollTop` moves
|
||||||
|
* this tile and nothing else. Measured from rectangles rather than
|
||||||
|
* `offsetTop` because the rows' offset parent is the tile, not the list.
|
||||||
|
*/
|
||||||
|
function keepInView(row) {
|
||||||
|
const list = listEl.getBoundingClientRect();
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
|
||||||
|
if (Math.abs(delta) < 1) return;
|
||||||
|
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||||
function renderLiveMarks() {
|
function renderLiveMarks() {
|
||||||
const live = state?.live || {};
|
const live = state?.live || {};
|
||||||
@@ -531,11 +659,12 @@ function createPlan({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||||
}
|
}
|
||||||
|
renderAbleSet();
|
||||||
if (currentId !== lastCurrentId) {
|
if (currentId !== lastCurrentId) {
|
||||||
lastCurrentId = currentId;
|
lastCurrentId = currentId;
|
||||||
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||||
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
||||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
if (row) keepInView(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,6 +739,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
|
renderAbleSet();
|
||||||
if (!state?.plan) return;
|
if (!state?.plan) return;
|
||||||
renderClock();
|
renderClock();
|
||||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||||
@@ -621,6 +751,7 @@ function createPlan({ root, moduleApi }) {
|
|||||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||||
state = payload.state;
|
state = payload.state;
|
||||||
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||||
|
if (payload.ableset) absView = payload.ableset;
|
||||||
feedOffline = false;
|
feedOffline = false;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -641,6 +772,16 @@ function createPlan({ root, moduleApi }) {
|
|||||||
accept(JSON.parse(ev.data));
|
accept(JSON.parse(ev.data));
|
||||||
} catch { /* a bad frame is not fatal */ }
|
} catch { /* a bad frame is not fatal */ }
|
||||||
},
|
},
|
||||||
|
// Its own event, several a second while a song plays: redraw that one
|
||||||
|
// row rather than the whole list.
|
||||||
|
ableset(ev) {
|
||||||
|
try {
|
||||||
|
absView = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state) renderAbleSet();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service and team assignments from Planning Center Services",
|
"description": "Order of service and team assignments from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -382,6 +382,18 @@
|
|||||||
"label": "Match line",
|
"label": "Match line",
|
||||||
"default": true,
|
"default": true,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"absProgress": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Song progress",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
|
"absSections": {
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Song sections",
|
||||||
|
"default": true,
|
||||||
|
"group": "Show"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tiles": [
|
"tiles": [
|
||||||
|
|||||||
+221
-2
@@ -9,6 +9,10 @@
|
|||||||
*
|
*
|
||||||
* GET /state → { state, now } (instant paint for new tiles)
|
* GET /state → { state, now } (instant paint for new tiles)
|
||||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||||
|
* plus `ableset` events: the song AbleSet is playing, its
|
||||||
|
* sections and playhead, when there is an AbleSet module
|
||||||
|
* to follow (see below). Its own event so the playhead
|
||||||
|
* never drags the plan onto the wire with it.
|
||||||
* POST /refresh → re-read the plan now
|
* POST /refresh → re-read the plan now
|
||||||
* GET /service-types → { options } for the admin "Service type" select,
|
* GET /service-types → { options } for the admin "Service type" select,
|
||||||
* grouped by Planning Center folder path
|
* grouped by Planning Center folder path
|
||||||
@@ -33,6 +37,13 @@
|
|||||||
* GET /service_types/{st}/item_note_categories (note category names)
|
* GET /service_types/{st}/item_note_categories (note category names)
|
||||||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
||||||
*
|
*
|
||||||
|
* Following AbleSet: the same loopback trick, entirely optional. When this
|
||||||
|
* ProdDash has an AbleSet module that is configured and answering, its song,
|
||||||
|
* sections and playhead are passed to the tiles, and the song is matched to a
|
||||||
|
* plan item by name so a tile can fill that item as the song plays. With no
|
||||||
|
* such module — not installed, disabled, no host — tiles are told
|
||||||
|
* `available: false` and draw none of it.
|
||||||
|
*
|
||||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* this module reads the propresenter-now-next module's own SSE feed over
|
||||||
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
|
||||||
@@ -59,7 +70,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||||
const {
|
const {
|
||||||
MINUTE,
|
MINUTE,
|
||||||
serviceTimes,
|
serviceTimes,
|
||||||
@@ -85,6 +96,12 @@ const HEARTBEAT_MS = 15000;
|
|||||||
/** The Now/Next module whose feed we follow. */
|
/** The Now/Next module whose feed we follow. */
|
||||||
const NOW_NEXT_ID = 'propresenter-now-next';
|
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||||
const NOW_NEXT_RETRY_MS = 3000;
|
const NOW_NEXT_RETRY_MS = 3000;
|
||||||
|
/** The AbleSet module, followed the same way — entirely optional (see below). */
|
||||||
|
const ABLESET_ID = 'ableset';
|
||||||
|
const ABLESET_RETRY_MS = 3000;
|
||||||
|
const ABLESET_MISSING_RETRY_MS = 15000;
|
||||||
|
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
|
||||||
|
const ABLESET_POS_MS = 200;
|
||||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||||
const SEEN_DOCUMENTS_MAX = 200;
|
const SEEN_DOCUMENTS_MAX = 200;
|
||||||
@@ -153,6 +170,28 @@ function clearedLive() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What tiles are told about AbleSet. `available` false covers every way there
|
||||||
|
* may be nothing to draw — no module installed, disabled, or not answering —
|
||||||
|
* and the tiles then show none of it at all.
|
||||||
|
*/
|
||||||
|
function clearedAbleSet() {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
configured: false,
|
||||||
|
reachable: false,
|
||||||
|
reason: '',
|
||||||
|
playing: false,
|
||||||
|
tempo: null,
|
||||||
|
song: null,
|
||||||
|
section: null,
|
||||||
|
sections: [],
|
||||||
|
beats: null,
|
||||||
|
itemId: '',
|
||||||
|
at: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The service that owns the clock, as tiles and the timers see it. */
|
/** The service that owns the clock, as tiles and the timers see it. */
|
||||||
function clearedService() {
|
function clearedService() {
|
||||||
return {
|
return {
|
||||||
@@ -1258,6 +1297,170 @@ module.exports = {
|
|||||||
const badPorts = new Set();
|
const badPorts = new Set();
|
||||||
|
|
||||||
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
|
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */
|
||||||
|
|
||||||
|
/* — AbleSet, over loopback like the ProPresenter feed above — */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AbleSet module, if this ProdDash has one. Nothing here is required:
|
||||||
|
* a dashboard with no AbleSet module, a disabled one, or one with no host
|
||||||
|
* configured simply reports `available: false` and the tiles draw none of
|
||||||
|
* it. Installing AbleSet later lights it up without a restart, because a
|
||||||
|
* 404 is retried on the slow timer like any other missing feed.
|
||||||
|
*
|
||||||
|
* What tiles get is deliberately small — the song, its sections, and the
|
||||||
|
* playhead in beats with a stamp — because it moves five times a second
|
||||||
|
* and must never drag the plan (items, teams) onto the wire with it. It
|
||||||
|
* rides its own `ableset` event on the module's one stream.
|
||||||
|
*/
|
||||||
|
let absRequest = null;
|
||||||
|
let absRetryTimer = null;
|
||||||
|
let absPosTimer = null;
|
||||||
|
let absLastSent = 0;
|
||||||
|
let absLastStill = '';
|
||||||
|
let abs = clearedAbleSet();
|
||||||
|
|
||||||
|
function absPart(p) {
|
||||||
|
if (!p || typeof p !== 'object') return null;
|
||||||
|
const name = String(p.name || '').trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
|
||||||
|
return { name, index: n(p.index), start: n(p.start), end: n(p.end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which plan item AbleSet's song is, by the same name matching the live
|
||||||
|
* ProPresenter document uses. Without this a progress bar would happily
|
||||||
|
* fill the sermon while the band rehearses the next song: the tile only
|
||||||
|
* draws progress when the song AbleSet is playing IS the item on screen.
|
||||||
|
*/
|
||||||
|
function absItemFor(songName) {
|
||||||
|
if (!songName) return '';
|
||||||
|
let best = null;
|
||||||
|
for (const item of state.items) {
|
||||||
|
if (item.itemType === 'header') continue;
|
||||||
|
const score = Math.max(
|
||||||
|
similarity(songName, item.title),
|
||||||
|
item.song ? similarity(songName, item.song.title) : 0
|
||||||
|
);
|
||||||
|
if (score >= MATCH_THRESHOLD && (!best || score > best.score)) best = { id: item.id, score };
|
||||||
|
}
|
||||||
|
return best ? best.id : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAbleSet(view) {
|
||||||
|
const song = absPart(view?.song);
|
||||||
|
abs = {
|
||||||
|
available: true,
|
||||||
|
configured: Boolean(view?.configured),
|
||||||
|
reachable: Boolean(view?.reachable),
|
||||||
|
playing: Boolean(view?.playing),
|
||||||
|
tempo: Number.isFinite(Number(view?.tempo)) ? Number(view.tempo) : null,
|
||||||
|
song,
|
||||||
|
section: absPart(view?.section),
|
||||||
|
sections: Array.isArray(view?.sections)
|
||||||
|
? view.sections.slice(0, 60).map((s) => ({ name: String(s?.name || ''), color: s?.color || null }))
|
||||||
|
: [],
|
||||||
|
beats: Number.isFinite(Number(view?.pos?.beats)) ? Number(view.pos.beats) : null,
|
||||||
|
itemId: song ? absItemFor(song.name) : '',
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
broadcastAbleSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the playhead moved: keep everything else and restamp. */
|
||||||
|
function applyAbleSetPos(pos) {
|
||||||
|
if (!abs.available) return;
|
||||||
|
abs = {
|
||||||
|
...abs,
|
||||||
|
beats: Number.isFinite(Number(pos?.beats)) ? Number(pos.beats) : abs.beats,
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
broadcastAbleSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan the slice out. A change in anything but the playhead goes at once;
|
||||||
|
* the playhead itself is throttled, since AbleSet offers more packets
|
||||||
|
* than any tile can draw.
|
||||||
|
*/
|
||||||
|
function broadcastAbleSet(force) {
|
||||||
|
const { beats, at, ...still } = abs;
|
||||||
|
const frame = JSON.stringify(still);
|
||||||
|
const changed = frame !== absLastStill;
|
||||||
|
absLastStill = frame;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && !changed && now - absLastSent < ABLESET_POS_MS) {
|
||||||
|
// Due soon anyway — make sure the last packet of a burst still lands.
|
||||||
|
if (!absPosTimer) {
|
||||||
|
absPosTimer = setTimeout(() => { absPosTimer = null; broadcastAbleSet(true); }, ABLESET_POS_MS);
|
||||||
|
absPosTimer.unref?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(absPosTimer);
|
||||||
|
absPosTimer = null;
|
||||||
|
absLastSent = now;
|
||||||
|
if (!streams.size) return;
|
||||||
|
writeAll(streams, `event: ableset\ndata: ${JSON.stringify(abs)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absUnavailable(reason, retryMs) {
|
||||||
|
if (abs.available || abs.reason !== reason) {
|
||||||
|
abs = { ...clearedAbleSet(), reason };
|
||||||
|
broadcastAbleSet(true);
|
||||||
|
}
|
||||||
|
if (stopped) return;
|
||||||
|
clearTimeout(absRetryTimer);
|
||||||
|
absRetryTimer = setTimeout(connectAbleSet, retryMs);
|
||||||
|
absRetryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectAbleSet() {
|
||||||
|
if (stopped) return;
|
||||||
|
const port = nnPort;
|
||||||
|
const req = http.get(
|
||||||
|
{ host: '127.0.0.1', port, path: `/api/modules/${ABLESET_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||||||
|
(res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
return absUnavailable(
|
||||||
|
res.statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${res.statusCode}`,
|
||||||
|
ABLESET_MISSING_RETRY_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
let buffer = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
buffer += chunk;
|
||||||
|
let idx;
|
||||||
|
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const block = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 2);
|
||||||
|
let event = 'message';
|
||||||
|
let data = '';
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (!data) continue;
|
||||||
|
try {
|
||||||
|
if (event === 'state') applyAbleSet(JSON.parse(data).state ?? JSON.parse(data));
|
||||||
|
else if (event === 'pos') applyAbleSetPos(JSON.parse(data));
|
||||||
|
} catch { /* a bad frame is not fatal */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on('end', () => absUnavailable('AbleSet module feed ended', ABLESET_RETRY_MS));
|
||||||
|
res.on('error', () => absUnavailable('AbleSet module feed dropped', ABLESET_RETRY_MS));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', (err) => {
|
||||||
|
if (req !== absRequest) return; // superseded by a reconnect on the learned port
|
||||||
|
absUnavailable(`ProdDash loopback failed: ${err.message}`, ABLESET_RETRY_MS);
|
||||||
|
});
|
||||||
|
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||||||
|
absRequest = req;
|
||||||
|
}
|
||||||
|
|
||||||
function learnPort(hostHeader) {
|
function learnPort(hostHeader) {
|
||||||
if (shellPort) return; // the shell told us; nothing to learn
|
if (shellPort) return; // the shell told us; nothing to learn
|
||||||
const port = portFromHost(hostHeader);
|
const port = portFromHost(hostHeader);
|
||||||
@@ -1269,6 +1472,10 @@ module.exports = {
|
|||||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
nnRequest = null;
|
nnRequest = null;
|
||||||
connectNowNext();
|
connectNowNext();
|
||||||
|
clearTimeout(absRetryTimer);
|
||||||
|
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
absRequest = null;
|
||||||
|
connectAbleSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1527,6 +1734,9 @@ module.exports = {
|
|||||||
state.live.reason = 'Connecting to the ProPresenter module feed';
|
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||||
connectNowNext();
|
connectNowNext();
|
||||||
}
|
}
|
||||||
|
// Always: whether there is an AbleSet module to follow is its answer, not
|
||||||
|
// an admin setting here.
|
||||||
|
connectAbleSet();
|
||||||
mirrorService();
|
mirrorService();
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
@@ -1559,6 +1769,7 @@ module.exports = {
|
|||||||
timerStreams,
|
timerStreams,
|
||||||
pco,
|
pco,
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
|
getAbleSet: () => abs,
|
||||||
learnPort,
|
learnPort,
|
||||||
refresh() {
|
refresh() {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
@@ -1580,7 +1791,10 @@ module.exports = {
|
|||||||
clearInterval(ticker);
|
clearInterval(ticker);
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
clearTimeout(nnRetryTimer);
|
clearTimeout(nnRetryTimer);
|
||||||
|
clearTimeout(absRetryTimer);
|
||||||
|
clearTimeout(absPosTimer);
|
||||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||||
|
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||||
for (const set of [streams, timerStreams]) {
|
for (const set of [streams, timerStreams]) {
|
||||||
for (const res of set) {
|
for (const res of set) {
|
||||||
try { res.end(); } catch { /* already gone */ }
|
try { res.end(); } catch { /* already gone */ }
|
||||||
@@ -1622,7 +1836,11 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
current?.learnPort(req.headers.host);
|
current?.learnPort(req.headers.host);
|
||||||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
json(res, 200, {
|
||||||
|
state: current ? current.getState() : clearedState(),
|
||||||
|
ableset: current ? current.getAbleSet() : clearedAbleSet(),
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /stream': (req, res) => {
|
'GET /stream': (req, res) => {
|
||||||
@@ -1630,6 +1848,7 @@ module.exports = {
|
|||||||
sseHead(res);
|
sseHead(res);
|
||||||
const state = current ? current.getState() : clearedState();
|
const state = current ? current.getState() : clearedState();
|
||||||
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||||||
|
res.write(`event: ableset\ndata: ${JSON.stringify(current ? current.getAbleSet() : clearedAbleSet())}\n\n`);
|
||||||
if (!current) return void res.end();
|
if (!current) return void res.end();
|
||||||
const { streams } = current;
|
const { streams } = current;
|
||||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-module="pco-plan"] .pp-row {
|
[data-module="pco-plan"] .pp-row {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.6em;
|
gap: 0.6em;
|
||||||
@@ -589,3 +590,70 @@
|
|||||||
|
|
||||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
|
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── AbleSet under the current item ───────────────────────────────────
|
||||||
|
Drawn only while an AbleSet module is configured, reachable, and playing
|
||||||
|
the song this item is. The fill is a layer behind the row's own content;
|
||||||
|
everything else in the row is lifted over it. */
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 0;
|
||||||
|
border-radius: 0.5em 0 0 0.5em;
|
||||||
|
background: color-mix(in srgb, var(--pp-current) 26%, transparent);
|
||||||
|
/* Packets arrive about five times a second; the glide covers the gaps. */
|
||||||
|
transition: width 0.22s linear;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* The sections strip, bottom right of the current item. */
|
||||||
|
[data-module="pco-plan"] .pp-abs-sections {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3em;
|
||||||
|
/* Bottom right of the item: hug the content and sit against the right edge
|
||||||
|
until there are more sections than fit, when it fills the width and
|
||||||
|
scrolls instead. */
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0.35em;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-bottom: 0.1em;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
|
||||||
|
mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
[data-module="pco-plan"] .pp-abs-sec {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.1em 0.5em;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
|
||||||
|
|
||||||
|
/* The section now playing fills left to right as it goes, like the row. */
|
||||||
|
[data-module="pco-plan"] .pp-abs-sec.is-now {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg,
|
||||||
|
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
|
||||||
|
transparent var(--pp-sec-fill, 0%));
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||||
const flowBtn = moduleApi.header.addButton({
|
const flowBtn = moduleApi.header.addButton({
|
||||||
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
moduleApi.header.addButton({
|
const latestBtn = moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Latest',
|
title: 'Latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
|
|
||||||
|
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||||
|
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||||
|
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||||
|
fullscreen fit, that used to take the channel filter with it. Below
|
||||||
|
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||||
|
the same actions, so the filter button is the last thing to go. */
|
||||||
|
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||||
|
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||||
|
|
||||||
|
let moreMenuEl = null;
|
||||||
|
const moreMenu = moduleApi.header.addMenu({
|
||||||
|
icon: MORE_SVG,
|
||||||
|
title: 'More',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('pt-menu');
|
||||||
|
moreMenuEl = menu;
|
||||||
|
rebuildMoreMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||||
|
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||||
|
|
||||||
|
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
|
if (on !== null) {
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
} else {
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
}
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = label;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
moreMenuEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMoreMenu() {
|
||||||
|
if (!moreMenuEl) return;
|
||||||
|
moreMenuEl.innerHTML = '';
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||||
|
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'pt-menu-divider';
|
||||||
|
moreMenuEl.appendChild(divider);
|
||||||
|
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||||
|
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||||
|
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||||
|
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let compact = null; // unknown until the first measurement
|
||||||
|
function applyCompact(width) {
|
||||||
|
const next = width > 0 && width < COMPACT_PX;
|
||||||
|
if (next === compact) return;
|
||||||
|
compact = next;
|
||||||
|
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||||
|
moreWrap.hidden = !compact;
|
||||||
|
if (!compact) moreMenu.close();
|
||||||
|
}
|
||||||
|
// the tile's own body tells the width; onResize only knows grid cells
|
||||||
|
const sizeWatch = new ResizeObserver((entries) => {
|
||||||
|
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||||
|
});
|
||||||
|
sizeWatch.observe(root);
|
||||||
|
applyCompact(root.clientWidth);
|
||||||
|
|
||||||
function applyInstanceSettings() {
|
function applyInstanceSettings() {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
flowBtn.classList.toggle('active', up);
|
flowBtn.classList.toggle('active', up);
|
||||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||||
|
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
|
|
||||||
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
sizeWatch.disconnect();
|
||||||
stream?.close();
|
stream?.close();
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||||
|
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||||
|
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── channels menu ──
|
/* ── channels menu ──
|
||||||
The menu container is created and positioned by the shell (.tile-menu);
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
it lives inside the tile, so [data-module] scoping still applies. */
|
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const DEFAULTS = {
|
|||||||
showDetail: true,
|
showDetail: true,
|
||||||
showSeconds: true,
|
showSeconds: true,
|
||||||
leadingZeros: false,
|
leadingZeros: false,
|
||||||
|
showLoops: false,
|
||||||
clock24h: true,
|
clock24h: true,
|
||||||
clockSeconds: true,
|
clockSeconds: true,
|
||||||
clockDate: false,
|
clockDate: false,
|
||||||
@@ -384,7 +385,12 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const source of state.sources) {
|
for (const source of state.sources) {
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
if (keyOf(source, timer) !== solo) continue;
|
if (keyOf(source, timer) !== solo) continue;
|
||||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
// A solo video tile with looping media hidden must not sit empty:
|
||||||
|
// it shows a quiet card instead of a countdown nobody should read.
|
||||||
|
const shown = timer.looping && pref('showLoops') !== true
|
||||||
|
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
|
||||||
|
: timer;
|
||||||
|
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The timer this tile was added for is not offered right now. Nothing is
|
// The timer this tile was added for is not offered right now. Nothing is
|
||||||
@@ -398,7 +404,11 @@ export default function create({ root, moduleApi }) {
|
|||||||
const items = [];
|
const items = [];
|
||||||
for (const timer of source.timers) {
|
for (const timer of source.timers) {
|
||||||
const key = keyOf(source, timer);
|
const key = keyOf(source, timer);
|
||||||
if (!hidden.has(key)) items.push({ key, source, timer });
|
if (hidden.has(key)) continue;
|
||||||
|
// A looping background's time means nothing — the card sits out
|
||||||
|
// while a loop plays (per tile: the "Looping videos" switch).
|
||||||
|
if (timer.looping && pref('showLoops') !== true) continue;
|
||||||
|
items.push({ key, source, timer });
|
||||||
}
|
}
|
||||||
// Every timer of a live source hidden on purpose: nothing to say.
|
// Every timer of a live source hidden on purpose: nothing to say.
|
||||||
if (!items.length && source.timers.length) continue;
|
if (!items.length && source.timers.length) continue;
|
||||||
@@ -666,7 +676,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.mono = null;
|
card.mono = null;
|
||||||
}
|
}
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||||
tone = toneFor(timer, value);
|
// A shown loop never warns or overruns — it wraps at zero by design,
|
||||||
|
// and a background flashing red every pass would cry wolf all service.
|
||||||
|
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
|
||||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
statusTone = timer.status?.tone || tone;
|
statusTone = timer.status?.tone || tone;
|
||||||
detailText = timer.detail || '';
|
detailText = timer.detail || '';
|
||||||
|
|||||||
@@ -191,9 +191,13 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
if (!tool.ready) {
|
if (!tool.ready) {
|
||||||
report({ error: tool.error, detail: tool.detail });
|
report({ error: tool.error, detail: tool.detail });
|
||||||
// A build in flight restarts us itself when it lands; anything else is
|
// onReady is only the FAST path (a successful build restarts us at
|
||||||
// config-shaped and should back off hard rather than spin.
|
// once). The slow retry must stay armed regardless: a failed build
|
||||||
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
// never calls back, and a binary can also arrive from outside — scp'd
|
||||||
|
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||||
|
// Waiting on onReady alone deadlocked exactly that way in production:
|
||||||
|
// "Capture tool building…" forever, with a working binary on disk.
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -161,6 +161,12 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
},
|
},
|
||||||
|
"showLoops": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Looping videos",
|
||||||
|
"default": false,
|
||||||
|
"group": "Show"
|
||||||
|
},
|
||||||
"showHeadings": {
|
"showHeadings": {
|
||||||
"type": "switch",
|
"type": "switch",
|
||||||
"label": "Source headings",
|
"label": "Source headings",
|
||||||
|
|||||||
@@ -255,6 +255,21 @@ function createProPresenterSource({ config, log }) {
|
|||||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||||
const phases = new Map();
|
const phases = new Map();
|
||||||
|
|
||||||
|
/* The video countdown — time left on what the presentation layer is
|
||||||
|
playing, from the transport routes (verified live on ProPresenter 21.4):
|
||||||
|
GET /v1/transport/presentation/current → { is_playing, uuid, name,
|
||||||
|
artist, audio_only, duration } (no looping flag exists — inferred)
|
||||||
|
GET /v1/transport/presentation/time → bare seconds, sub-second
|
||||||
|
float — better than /v1/timer/video_countdown's whole-second string,
|
||||||
|
so no quantization phase lock is needed, only an anchor hold. */
|
||||||
|
let media = null; // { uuid, name, duration, playing } from the slow lane
|
||||||
|
let mediaAt = 0;
|
||||||
|
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
|
||||||
|
let videoPos = null; // playback position in seconds (float)
|
||||||
|
let videoAsOf = 0; // server ms when videoPos was measured
|
||||||
|
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
|
||||||
|
let loopingUuid = ''; // media seen wrapping — a looping background
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
if (password) built.searchParams.set('password', password);
|
if (password) built.searchParams.set('password', password);
|
||||||
@@ -390,6 +405,121 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow the presentation transport layer. Failures here only drop the
|
||||||
|
* video card, never the whole source — the timers must survive a
|
||||||
|
* ProPresenter build with transport quirks.
|
||||||
|
*/
|
||||||
|
async function pollVideo() {
|
||||||
|
if (tick % SLOW_EVERY === 1) {
|
||||||
|
try {
|
||||||
|
const raw = await requestJson('/v1/transport/presentation/current');
|
||||||
|
const uuid = String(raw?.uuid || '');
|
||||||
|
if (!media || media.uuid !== uuid) {
|
||||||
|
if (media && media.uuid !== uuid) loopingUuid = '';
|
||||||
|
videoPos = null;
|
||||||
|
videoAnchor = null;
|
||||||
|
}
|
||||||
|
media = uuid ? {
|
||||||
|
uuid,
|
||||||
|
name: String(raw.name || ''),
|
||||||
|
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
|
||||||
|
playing: Boolean(raw.is_playing),
|
||||||
|
} : null;
|
||||||
|
mediaAt = Date.now();
|
||||||
|
transportOk = true;
|
||||||
|
} catch (err) {
|
||||||
|
media = null; // the slow lane retries in ~2 s
|
||||||
|
if (err && err.status === 404) transportOk = false; // no transport route on this build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!media) {
|
||||||
|
videoPos = null;
|
||||||
|
videoAnchor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pos = Number(await requestJson('/v1/transport/presentation/time'));
|
||||||
|
if (Number.isFinite(pos) && pos >= 0) {
|
||||||
|
// Same media, position jumped back by more than a stutter: it
|
||||||
|
// wrapped — a looping background. Sticks until the media changes.
|
||||||
|
// (A video manually re-triggered from the start looks the same and
|
||||||
|
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
|
||||||
|
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
|
||||||
|
videoPos = pos;
|
||||||
|
videoAsOf = Date.now();
|
||||||
|
} else {
|
||||||
|
videoPos = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
videoPos = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The video countdown as one synthetic timer card (id 'video'). The card
|
||||||
|
persists as an idle "—" between videos — a wall tile must not appear
|
||||||
|
and vanish with the media — and is only absent entirely on a
|
||||||
|
ProPresenter without transport routes. */
|
||||||
|
function videoTimer() {
|
||||||
|
if (transportOk !== true) return null;
|
||||||
|
if (!media) {
|
||||||
|
return {
|
||||||
|
id: 'video',
|
||||||
|
label: 'Video',
|
||||||
|
kind: 'countdown',
|
||||||
|
state: 'idle',
|
||||||
|
asOf: mediaAt || updatedAt,
|
||||||
|
startedAt: null,
|
||||||
|
elapsedMs: null,
|
||||||
|
remainingMs: null,
|
||||||
|
targetMs: null,
|
||||||
|
looping: false,
|
||||||
|
status: null,
|
||||||
|
detail: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const playing = media.playing;
|
||||||
|
const dur = media.duration > 0 ? media.duration * 1000 : null;
|
||||||
|
let remainingMs = null;
|
||||||
|
let asOf = videoAsOf || mediaAt;
|
||||||
|
if (videoPos !== null && dur !== null) {
|
||||||
|
remainingMs = Math.max(0, dur - videoPos * 1000);
|
||||||
|
if (playing) {
|
||||||
|
// Hold the published anchor while it still predicts the measured
|
||||||
|
// position: the tiles extrapolate locally, and a fresh anchor per
|
||||||
|
// poll would broadcast a frame every 500 ms for nothing.
|
||||||
|
const p = videoAnchor;
|
||||||
|
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
|
||||||
|
if (p && Math.abs(predicted - remainingMs) < 120) {
|
||||||
|
remainingMs = p.remainingMs;
|
||||||
|
asOf = p.asOf;
|
||||||
|
} else {
|
||||||
|
videoAnchor = { remainingMs, asOf };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoAnchor = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoAnchor = null;
|
||||||
|
}
|
||||||
|
const looping = media.uuid === loopingUuid;
|
||||||
|
return {
|
||||||
|
id: 'video',
|
||||||
|
label: 'Video',
|
||||||
|
kind: 'countdown',
|
||||||
|
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
|
||||||
|
asOf,
|
||||||
|
startedAt: null,
|
||||||
|
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
|
||||||
|
remainingMs,
|
||||||
|
targetMs: dur,
|
||||||
|
looping,
|
||||||
|
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
|
||||||
|
// The media name, without the file extension nobody reads on a wall.
|
||||||
|
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function status() {
|
function status() {
|
||||||
if (!configured) return 'error';
|
if (!configured) return 'error';
|
||||||
if (reachable) return 'ok';
|
if (reachable) return 'ok';
|
||||||
@@ -429,6 +559,9 @@ function createProPresenterSource({ config, log }) {
|
|||||||
const currentTimes = await requestJson('/v1/timers/current');
|
const currentTimes = await requestJson('/v1/timers/current');
|
||||||
updatedAt = Date.now();
|
updatedAt = Date.now();
|
||||||
timers = mergeTimers(currentTimes, updatedAt);
|
timers = mergeTimers(currentTimes, updatedAt);
|
||||||
|
await pollVideo();
|
||||||
|
const video = videoTimer();
|
||||||
|
if (video) timers.push(video);
|
||||||
reachable = true;
|
reachable = true;
|
||||||
lastError = '';
|
lastError = '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -970,6 +1103,8 @@ const GEAR = {
|
|||||||
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
|
||||||
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
|
||||||
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
|
||||||
|
/** The video countdown: a timer card, plus whether looping media counts. */
|
||||||
|
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
|
||||||
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
|
||||||
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
|
||||||
/** The time of day: plain digits, so only how they read. */
|
/** The time of day: plain digits, so only how they read. */
|
||||||
@@ -983,6 +1118,7 @@ function gearFor(source, timer) {
|
|||||||
if (source.kind === 'ltc') return GEAR.ltc;
|
if (source.kind === 'ltc') return GEAR.ltc;
|
||||||
if (source.kind === 'clock') return GEAR.clock;
|
if (source.kind === 'clock') return GEAR.clock;
|
||||||
if (timer.kind === 'clock') return GEAR.providerClock;
|
if (timer.kind === 'clock') return GEAR.providerClock;
|
||||||
|
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
|
||||||
return GEAR.timer;
|
return GEAR.timer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,6 +1327,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'] : [];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.4.1",
|
"version": "0.4.3",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createSplListener } = require('./spl-listener');
|
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
|
||||||
|
|
||||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||||
const CALIBRATOR_DB = 94;
|
const CALIBRATOR_DB = 94;
|
||||||
@@ -114,11 +114,19 @@ module.exports = {
|
|||||||
error: '',
|
error: '',
|
||||||
streams: new Set(),
|
streams: new Set(),
|
||||||
silentSince: null,
|
silentSince: null,
|
||||||
|
toolMissing: '',
|
||||||
listener: null,
|
listener: null,
|
||||||
lastSent: '',
|
lastSent: '',
|
||||||
pinger: null,
|
pinger: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Before anything else: the device picker shells out to this tool, so a
|
||||||
|
// machine that installed the module from the catalog (source only — the
|
||||||
|
// binary is gitignored) needs the build kicked off here. Waiting for a
|
||||||
|
// listener would never happen: no tool means no device list, and no
|
||||||
|
// device means no listener.
|
||||||
|
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
@@ -177,6 +185,9 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
stop() { teardown(); },
|
stop() { teardown(); },
|
||||||
health() {
|
health() {
|
||||||
|
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
||||||
|
return { status: 'error', message: state.toolMissing };
|
||||||
|
}
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
const quiet = silentMs();
|
const quiet = silentMs();
|
||||||
@@ -300,6 +311,13 @@ module.exports = {
|
|||||||
/** Device list for the admin page. Runs the capture tool's --list,
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
which is the only thing that knows what CoreAudio can see. */
|
which is the only thing that knows what CoreAudio can see. */
|
||||||
'GET /devices': async (req, res) => {
|
'GET /devices': async (req, res) => {
|
||||||
|
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||||
|
if (missing) {
|
||||||
|
if (state) state.toolMissing = missing;
|
||||||
|
log(`device list unavailable — ${missing}`);
|
||||||
|
json(res, 200, { options: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const devices = await listDevices();
|
const devices = await listDevices();
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
@@ -438,6 +456,10 @@ function listDevices() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fsExists(p) {
|
||||||
|
try { return require('fs').existsSync(p); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
function json(res, code, body) {
|
function json(res, code, body) {
|
||||||
const text = JSON.stringify(body);
|
const text = JSON.stringify(body);
|
||||||
res.writeHead(code, {
|
res.writeHead(code, {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||||
* the app, or copied from any Mac of the same architecture. When it is
|
* the app, or copied from any Mac of the same architecture. When it is
|
||||||
* missing this will build it, but in the background and never on the event
|
* missing this will build it, but in the background and never on the event
|
||||||
* loop: see ensureBinary for what that cost the first time it was wrong.
|
* loop, and from module scope so the device picker can trigger it too — see
|
||||||
|
* ensureCaptureBinary for what both of those cost the first time round.
|
||||||
*
|
*
|
||||||
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||||
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||||
@@ -30,6 +31,74 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||||
|
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||||
|
+ 'and copy it next to spl-capture.swift';
|
||||||
|
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
|
||||||
|
* everything that needs it.
|
||||||
|
*
|
||||||
|
* This lives at module scope rather than inside a listener because of a trap
|
||||||
|
* the first version walked into: the admin page's device picker runs the tool
|
||||||
|
* with --list, but the build only ran when a listener started, and a listener
|
||||||
|
* only starts once a device has been picked. On a machine that got the module
|
||||||
|
* from the catalog — source only, the binary being gitignored — the picker
|
||||||
|
* was empty, and nothing would ever fill it.
|
||||||
|
*
|
||||||
|
* It also must never block. It was a spawnSync once, copied from the LTC
|
||||||
|
* listener where the source is a third the size and builds in seconds. Here
|
||||||
|
* it took nearly two minutes on the production Mac and held the Node event
|
||||||
|
* loop for all of it, freezing every other module, then did it again on the
|
||||||
|
* next retry.
|
||||||
|
*
|
||||||
|
* The binary should normally already be there: built beside the app, or
|
||||||
|
* copied from any Mac of the same architecture. This is the fallback.
|
||||||
|
*
|
||||||
|
* @returns {string} '' when the tool is ready, otherwise what to say.
|
||||||
|
*/
|
||||||
|
let building = false;
|
||||||
|
let buildFailed = false;
|
||||||
|
|
||||||
|
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||||
|
const bin = path.join(moduleDir, 'spl-capture');
|
||||||
|
if (fs.existsSync(bin)) return '';
|
||||||
|
const src = bin + '.swift';
|
||||||
|
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||||
|
if (building) return BUILDING;
|
||||||
|
if (buildFailed) return BUILD_HELP;
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
log('spl-capture binary missing — building it in the background');
|
||||||
|
const build = spawn('swiftc', ['-O', '-o', bin, 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);
|
||||||
|
build.on('error', (err) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false; buildFailed = true;
|
||||||
|
log(`swiftc could not be run: ${err.message}`);
|
||||||
|
});
|
||||||
|
build.on('exit', (code) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
if (code === 0 && fs.existsSync(bin)) {
|
||||||
|
log('spl-capture built');
|
||||||
|
buildFailed = false;
|
||||||
|
if (typeof onReady === 'function') onReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = true;
|
||||||
|
log(`spl-capture build failed (exit ${code})`
|
||||||
|
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||||
|
});
|
||||||
|
return BUILDING;
|
||||||
|
}
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||||
retrying every 5 s would only fill the log with the same sentence. */
|
retrying every 5 s would only fill the log with the same sentence. */
|
||||||
@@ -53,64 +122,13 @@ function createSplListener(opts) {
|
|||||||
let stallTimer = null;
|
let stallTimer = null;
|
||||||
let startedAt = 0;
|
let startedAt = 0;
|
||||||
let meta = null;
|
let meta = null;
|
||||||
let building = false;
|
|
||||||
let buildFailed = false;
|
|
||||||
let lastFrameWall = 0;
|
let lastFrameWall = 0;
|
||||||
|
|
||||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
/** '' when the tool is ready, otherwise what to say about it. */
|
||||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
|
||||||
+ 'and copy it next to spl-capture.swift';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the capture tool if it is missing, WITHOUT blocking.
|
|
||||||
*
|
|
||||||
* This used to be a spawnSync, copied from the LTC listener where the
|
|
||||||
* source is a third the size and builds in a few seconds. Here it took
|
|
||||||
* nearly two minutes on the production Mac and froze the entire ProdDash
|
|
||||||
* server for every second of it — one module's optional helper took the
|
|
||||||
* whole dashboard down, then did it again on every retry. Nothing that
|
|
||||||
* runs while a service might be on is allowed to hold the event loop.
|
|
||||||
*
|
|
||||||
* The build is a fallback for a module installed from the catalog on a
|
|
||||||
* machine with a Swift toolchain. The normal path is a binary built
|
|
||||||
* alongside the app, or copied from any same-architecture Mac.
|
|
||||||
*
|
|
||||||
* @returns {string} '' when the binary is ready, otherwise what to say.
|
|
||||||
*/
|
|
||||||
function ensureBinary() {
|
function ensureBinary() {
|
||||||
if (fs.existsSync(bin)) return '';
|
return ensureCaptureBinary({ moduleDir, log, onReady() {
|
||||||
const src = bin + '.swift';
|
|
||||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
|
||||||
if (building) return 'Building the capture tool — this takes a minute or two';
|
|
||||||
if (buildFailed) return BUILD_HELP;
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
log('spl-capture binary missing — building it in the background');
|
|
||||||
const build = spawn('swiftc', ['-O', '-o', bin, 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);
|
|
||||||
build.on('error', (err) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false; buildFailed = true;
|
|
||||||
log(`swiftc could not be run: ${err.message}`);
|
|
||||||
});
|
|
||||||
build.on('exit', (code) => {
|
|
||||||
clearTimeout(giveUp);
|
|
||||||
building = false;
|
|
||||||
if (code === 0 && fs.existsSync(bin)) {
|
|
||||||
log('spl-capture built');
|
|
||||||
buildFailed = false;
|
|
||||||
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||||
return;
|
} });
|
||||||
}
|
|
||||||
buildFailed = true;
|
|
||||||
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
|
||||||
});
|
|
||||||
return 'Building the capture tool — this takes a minute or two';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRestart(ms) {
|
function scheduleRestart(ms) {
|
||||||
@@ -135,9 +153,13 @@ function createSplListener(opts) {
|
|||||||
const missing = ensureBinary();
|
const missing = ensureBinary();
|
||||||
if (missing) {
|
if (missing) {
|
||||||
onUpdate({ error: missing });
|
onUpdate({ error: missing });
|
||||||
// A build in flight calls start() itself when it lands; anything else
|
// onReady is only the FAST path (a successful build restarts us at
|
||||||
// is config-shaped and should back off hard rather than spin.
|
// once). The slow retry must stay armed regardless: a failed build
|
||||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
// never calls back, and a binary can also arrive from outside — scp'd
|
||||||
|
// in on a machine whose toolchain can't build (AB's broken CLT).
|
||||||
|
// Waiting on onReady alone deadlocked exactly that way in the Timers
|
||||||
|
// module: "building…" forever, with a working binary on disk.
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,4 +258,4 @@ function createSplListener(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSplListener };
|
module.exports = { createSplListener, ensureCaptureBinary };
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.9.0",
|
"version": "1.9.4",
|
||||||
"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",
|
||||||
|
|||||||
+41
-10
@@ -639,8 +639,10 @@ function buildTile(tile) {
|
|||||||
el.addEventListener('pointermove', (ev) => {
|
el.addEventListener('pointermove', (ev) => {
|
||||||
if (!headIsOff(tile)) return;
|
if (!headIsOff(tile)) return;
|
||||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
// — neither is a moment to take the bar away.
|
// — neither is a moment to take the bar away. Nor is an open header
|
||||||
|
// menu, which hangs below the bar and is about to be pointed at.
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return;
|
||||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
const shown = el.classList.contains('head-peek');
|
const shown = el.classList.contains('head-peek');
|
||||||
// It takes less to hold the bar open than to open it, so a bar that
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
@@ -651,6 +653,7 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||||
el.classList.remove('head-peek');
|
el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,7 +661,7 @@ function buildTile(tile) {
|
|||||||
applyHeadState(tile, el);
|
applyHeadState(tile, el);
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
|
||||||
tileEls.set(tile.id, entry);
|
tileEls.set(tile.id, entry);
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
@@ -1416,34 +1419,60 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
menu.className = 'tile-menu';
|
menu.className = 'tile-menu';
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
|
|
||||||
|
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||||
|
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||||
|
and either one makes a fixed-positioned descendant position
|
||||||
|
itself against the bar instead of the viewport — the menu landed
|
||||||
|
a screen-height below its button, inside the tile's overflow, and
|
||||||
|
never showed. Its host carries the tile's data-module so the
|
||||||
|
module's scoped CSS still reaches the items. */
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'tile-menu-host';
|
||||||
|
if (man) host.dataset.module = man.id;
|
||||||
|
host.appendChild(menu);
|
||||||
|
|
||||||
|
const setOpen = (on) => {
|
||||||
|
if (on === !menu.hidden) return;
|
||||||
|
menu.hidden = !on;
|
||||||
|
btn.classList.toggle('is-open', on);
|
||||||
|
if (on) entry.openMenus.add(menu);
|
||||||
|
else entry.openMenus.delete(menu);
|
||||||
|
// an open menu holds a folded-away bar out; when the last one
|
||||||
|
// closes with the pointer elsewhere, the bar goes back
|
||||||
|
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||||
|
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||||
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (typeof build === 'function') {
|
if (typeof build === 'function') {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
build(menu);
|
build(menu);
|
||||||
}
|
}
|
||||||
menu.hidden = false;
|
setOpen(true);
|
||||||
// fixed positioning (escapes the tile's overflow): under the
|
// under the button, right-aligned, kept whole inside the viewport
|
||||||
// button, right-aligned, clamped to the viewport
|
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
|
||||||
menu.style.left = '0px';
|
menu.style.left = '0px';
|
||||||
|
menu.style.top = '0px';
|
||||||
const w = menu.offsetWidth;
|
const w = menu.offsetWidth;
|
||||||
|
const h = menu.offsetHeight;
|
||||||
|
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||||
};
|
};
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (menu.hidden) open();
|
if (menu.hidden) open();
|
||||||
else menu.hidden = true;
|
else setOpen(false);
|
||||||
});
|
});
|
||||||
const onDocClick = (e) => {
|
const onDocClick = (e) => {
|
||||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', onDocClick);
|
document.addEventListener('click', onDocClick);
|
||||||
docListeners.add(onDocClick);
|
docListeners.add(onDocClick);
|
||||||
|
|
||||||
wrap.append(btn, menu);
|
wrap.append(btn);
|
||||||
entry.controls.appendChild(wrap);
|
entry.controls.appendChild(wrap);
|
||||||
|
document.body.appendChild(host);
|
||||||
headerEls.add(wrap);
|
headerEls.add(wrap);
|
||||||
|
headerEls.add(host);
|
||||||
return {
|
return {
|
||||||
button: btn,
|
button: btn,
|
||||||
menu,
|
menu,
|
||||||
@@ -1451,7 +1480,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
menu.hidden = true;
|
setOpen(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1463,6 +1492,8 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
for (const handle of [...sseHandles]) handle.close();
|
for (const handle of [...sseHandles]) handle.close();
|
||||||
for (const el of headerEls) el.remove();
|
for (const el of headerEls) el.remove();
|
||||||
headerEls.clear();
|
headerEls.clear();
|
||||||
|
entry.openMenus.clear();
|
||||||
|
entry.el.classList.remove('menu-open');
|
||||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||||
docListeners.clear();
|
docListeners.clear();
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-2
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile-dd { position: relative; display: inline-flex; }
|
.tile-dd { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||||
overflow:hidden can't clip it */
|
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||||
|
.tile-menu-host { display: contents; }
|
||||||
.tile-menu {
|
.tile-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
|
|||||||
@@ -373,7 +373,18 @@ function mountModule(id) {
|
|||||||
const log = makeLog(id);
|
const log = makeLog(id);
|
||||||
try {
|
try {
|
||||||
const serverPath = path.join(man.dir, man.server);
|
const serverPath = path.join(man.dir, man.server);
|
||||||
// Re-require fresh config on every (re)mount; the module code itself stays cached.
|
// Drop the module's code from the require cache so every (re)mount runs
|
||||||
|
// the files on disk. Without this a module UPDATE swaps the folder while
|
||||||
|
// the process keeps executing the old code until ProdDash itself
|
||||||
|
// restarts — the manifest (read fresh from disk) then reports a version
|
||||||
|
// the running code isn't, and anything new in the update silently
|
||||||
|
// doesn't happen. Modules already have to come up clean on re-init
|
||||||
|
// (module guide), so a fresh load on config remounts is the same
|
||||||
|
// contract without the trap.
|
||||||
|
const cachePrefix = man.dir.endsWith(path.sep) ? man.dir : man.dir + path.sep;
|
||||||
|
for (const key of Object.keys(require.cache)) {
|
||||||
|
if (key.startsWith(cachePrefix)) delete require.cache[key];
|
||||||
|
}
|
||||||
const mod = require(serverPath);
|
const mod = require(serverPath);
|
||||||
const config = effectiveConfig(id);
|
const config = effectiveConfig(id);
|
||||||
// What a module needs to reach the rest of ProdDash — another module's
|
// What a module needs to reach the rest of ProdDash — another module's
|
||||||
@@ -1060,13 +1071,22 @@ async function installModule(id) {
|
|||||||
if (download) {
|
if (download) {
|
||||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
const shipped = new Set();
|
||||||
for (const [rel, data] of remoteEntry.files) {
|
for (const [rel, data] of remoteEntry.files) {
|
||||||
if (!safeRelPath(rel)) continue;
|
if (!safeRelPath(rel)) continue;
|
||||||
|
shipped.add(rel);
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
fs.writeFileSync(target, data);
|
||||||
}
|
}
|
||||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
|
// Per-machine executables — a capture tool compiled here, or copied in
|
||||||
|
// by hand on a machine whose toolchain can't build it — are gitignored,
|
||||||
|
// so the repo never ships them. Replacing the folder must not destroy
|
||||||
|
// them: carry executables the download doesn't include over from the
|
||||||
|
// copy being replaced (or from the app's bundled copy on a first
|
||||||
|
// install), or "update" quietly kills a working module.
|
||||||
|
carryOverExecutables([dest, path.join(BUNDLED_MODULES_DIR, id)], staging, shipped);
|
||||||
unmountModule(id);
|
unmountModule(id);
|
||||||
fs.rmSync(dest, { recursive: true, force: true });
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
fs.renameSync(staging, dest);
|
fs.renameSync(staging, dest);
|
||||||
@@ -1083,6 +1103,43 @@ async function installModule(id) {
|
|||||||
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
return { id, version: String(now?.version || ''), source: now?.source || '', downloaded: download };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy executable files (any execute bit) that `shipped` does not include
|
||||||
|
* from the first existing source dir into `staging`, keeping their relative
|
||||||
|
* path and mode. Executables only, on purpose: they are the per-machine
|
||||||
|
* build artifacts (gitignored, so never in a download), while stray text
|
||||||
|
* files from an old version — code an update deliberately removed — must
|
||||||
|
* NOT be resurrected.
|
||||||
|
*/
|
||||||
|
function carryOverExecutables(sourceDirs, staging, shipped) {
|
||||||
|
const from = sourceDirs.find((d) => {
|
||||||
|
try { return fs.statSync(d).isDirectory(); } catch { return false; }
|
||||||
|
});
|
||||||
|
if (!from) return;
|
||||||
|
const walk = (rel) => {
|
||||||
|
for (const e of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) {
|
||||||
|
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
walk(relPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!e.isFile() || shipped.has(relPath) || !safeRelPath(relPath)) continue;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(path.join(from, relPath));
|
||||||
|
if (!(stat.mode & 0o111)) continue;
|
||||||
|
const target = path.join(staging, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.copyFileSync(path.join(from, relPath), target);
|
||||||
|
fs.chmodSync(target, stat.mode);
|
||||||
|
console.log(`[proddash] module update kept per-machine executable "${relPath}"`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[proddash] could not carry "${relPath}" across the update:`, err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk('');
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
/** Remove a module: an installed copy is deleted; a bundled one is switched
|
||||||
off and hidden (its folder belongs to the app). Settings are kept so a
|
off and hidden (its folder belongs to the app). Settings are kept so a
|
||||||
later reinstall picks them up again. */
|
later reinstall picks them up again. */
|
||||||
|
|||||||
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
|
|||||||
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
|
||||||
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
|
||||||
|
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
|
||||||
|
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
|
||||||
|
|
||||||
sendJson(res, { error: 'not found' }, 404);
|
sendJson(res, { error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── transport (video countdown): a 4-minute cycle ─────────────────
|
||||||
|
0–60 s "Sermon Bumper.mp4" (3:00) playing
|
||||||
|
60–75 s the same bumper paused at 1:00
|
||||||
|
75–90 s playing again
|
||||||
|
90–120 s nothing loaded
|
||||||
|
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
|
||||||
|
must infer looping from the position jumping back.
|
||||||
|
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
|
||||||
|
name, artist, audio_only, duration }, `time` = bare seconds (float). */
|
||||||
|
function transport() {
|
||||||
|
const phase = ((Date.now() - startedAt) / 1000) % 240;
|
||||||
|
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
|
||||||
|
if (phase < 90) {
|
||||||
|
const playing = phase < 60 || phase >= 75;
|
||||||
|
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
|
||||||
|
return {
|
||||||
|
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
|
||||||
|
time: Math.round(pos * 10) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (phase < 120) return none;
|
||||||
|
return {
|
||||||
|
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
|
||||||
|
time: Math.round(((phase - 120) % 15) * 10) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
|
||||||
|
|
||||||
const cryptoMod = require('crypto');
|
const cryptoMod = require('crypto');
|
||||||
|
|||||||
Reference in New Issue
Block a user