Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 aec9c47ed4 PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile
Three things, the first on its own.

**Auto-scroll moves the tile, not the board.** `scrollIntoView` walks up and
scrolls every ancestor that can scroll, so bringing the current item into
view dragged the whole dashboard with it whenever the board was taller than
the window. The list's own `scrollTop` is moved instead, measured from
rectangles because the rows' offset parent is the tile rather than the list.

**The current item fills as its song plays.** When an AbleSet module is
installed, configured and playing a song that IS the item on screen, that
item's background fills left to right with the song. The song is tied to a
plan item by the same name matching the live ProPresenter document already
uses, so a bar can never quietly fill the sermon while the band rehearses
something else.

**Its sections slide past, bottom right of that item.** Every section of the
song as a strip, the played ones dimmed, the one playing filling in turn and
kept centred — by moving that strip's own `scrollLeft`, for the same reason
as above. The chips are rebuilt only when the song changes; moving to the
next section just moves the classes, so the strip keeps its place.

Both are switches in the tile's gear, **Song progress** and **Song
sections**, and both draw nothing at all unless there is an AbleSet module
to follow: the server reports `available: false` for no module, a disabled
one, or one with no host, and re-checks every 15 s so installing AbleSet
later lights it up without a restart.

The server follows the AbleSet module over loopback exactly as it follows
ProPresenter's, and republishes a small slice — song, sections, playhead in
beats with a stamp — as its own `ableset` SSE event. Its own event because
it moves five times a second and must never drag the plan (items, teams)
onto the wire with it; the tile carries the playhead forward from the tempo
between packets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 09:14:19 -04:00
5 changed files with 453 additions and 4 deletions
+9
View File
@@ -346,6 +346,15 @@ below. Nothing is installed without someone clicking.
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
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
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
+142 -1
View File
@@ -34,6 +34,7 @@ function createPlan({ root, moduleApi }) {
let ticker = null;
let lastCurrentId = null;
let lastListKey = '';
let absView = null; // what the server says AbleSet is doing, if anything
let userScrolledAt = 0;
root.innerHTML = `
@@ -493,6 +494,133 @@ function createPlan({ root, moduleApi }) {
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. */
function renderLiveMarks() {
const live = state?.live || {};
@@ -531,11 +659,12 @@ function createPlan({ root, moduleApi }) {
}
if (!h.endedAt) elapsedEl.classList.add('is-running');
}
renderAbleSet();
if (currentId !== lastCurrentId) {
lastCurrentId = currentId;
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
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() {
renderAbleSet();
if (!state?.plan) return;
renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
@@ -621,6 +751,7 @@ function createPlan({ root, moduleApi }) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
if (payload.ableset) absView = payload.ableset;
feedOffline = false;
render();
}
@@ -641,6 +772,16 @@ function createPlan({ root, moduleApi }) {
accept(JSON.parse(ev.data));
} 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();
},
},
});
}
+13 -1
View File
@@ -1,7 +1,7 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.7.0",
"version": "1.8.0",
"proddash": ">=1.4.0",
"description": "Order of service and team assignments from Planning Center Services",
"provides": [
@@ -382,6 +382,18 @@
"label": "Match line",
"default": true,
"group": "Show"
},
"absProgress": {
"type": "boolean",
"label": "Song progress",
"default": true,
"group": "Show"
},
"absSections": {
"type": "boolean",
"label": "Song sections",
"default": true,
"group": "Show"
}
},
"tiles": [
+221 -2
View File
@@ -9,6 +9,10 @@
*
* GET /state → { state, now } (instant paint for new tiles)
* 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
* GET /service-types → { options } for the admin "Service type" select,
* grouped by Planning Center folder path
@@ -33,6 +37,13 @@
* GET /service_types/{st}/item_note_categories (note category names)
* 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,
* this module reads the propresenter-now-next module's own SSE feed over
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
@@ -59,7 +70,7 @@ const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
const {
MINUTE,
serviceTimes,
@@ -85,6 +96,12 @@ const HEARTBEAT_MS = 15000;
/** The Now/Next module whose feed we follow. */
const NOW_NEXT_ID = 'propresenter-now-next';
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;
/** How many distinct documents seen live are remembered for the admin selects. */
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. */
function clearedService() {
return {
@@ -1258,6 +1297,170 @@ module.exports = {
const badPorts = new Set();
/** 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) {
if (shellPort) return; // the shell told us; nothing to learn
const port = portFromHost(hostHeader);
@@ -1269,6 +1472,10 @@ module.exports = {
try { nnRequest?.destroy(); } catch { /* gone */ }
nnRequest = null;
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';
connectNowNext();
}
// Always: whether there is an AbleSet module to follow is its answer, not
// an admin setting here.
connectAbleSet();
mirrorService();
const heartbeat = setInterval(() => {
@@ -1559,6 +1769,7 @@ module.exports = {
timerStreams,
pco,
getState: () => state,
getAbleSet: () => abs,
learnPort,
refresh() {
clearTimeout(pollTimer);
@@ -1580,7 +1791,10 @@ module.exports = {
clearInterval(ticker);
clearTimeout(pollTimer);
clearTimeout(nnRetryTimer);
clearTimeout(absRetryTimer);
clearTimeout(absPosTimer);
try { nnRequest?.destroy(); } catch { /* gone */ }
try { absRequest?.destroy(); } catch { /* gone */ }
for (const set of [streams, timerStreams]) {
for (const res of set) {
try { res.end(); } catch { /* already gone */ }
@@ -1622,7 +1836,11 @@ module.exports = {
return {
'GET /state': (req, res) => {
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) => {
@@ -1630,6 +1848,7 @@ module.exports = {
sseHead(res);
const state = current ? current.getState() : clearedState();
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();
const { streams } = current;
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
+68
View File
@@ -86,6 +86,7 @@
}
[data-module="pco-plan"] .pp-row {
position: relative;
display: flex;
align-items: flex-start;
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-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%));
}