diff --git a/README.md b/README.md
index 85baf5e..f1f434f 100644
--- a/README.md
+++ b/README.md
@@ -323,8 +323,9 @@ below. Nothing is installed without someone clicking.
session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
-- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
- **Plan** (the order of service) and **Teams** (who is scheduled).
+- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
+ **Plan** (the order of service), **Teams** (who is scheduled) and **Song
+ Key** (the key of the item on screen).
**Plan** shows every
plan item with its scheduled start time, length, type, song key,
@@ -355,6 +356,12 @@ below. Nothing is installed without someone clicking.
is switched off, or it has no host — nothing to turn off and nothing to
explain.
+ **Song Key** is one value, as large as the tile allows: the key of the item
+ ProPresenter has live, set as a note (B♭, F♯m), with the song's name and the
+ next key under it — both switches, as is the colour. When the feed that
+ picks the item is gone the key stays up, faded, and the tile's dot says so
+ rather than pretending to be current.
+
**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
diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js
index 451bdda..ca2f29d 100644
--- a/modules/pco-plan/client.js
+++ b/modules/pco-plan/client.js
@@ -1,8 +1,8 @@
/* PCO Plan — client part.
- The module presents two tiles and this is the entry for both: the Teams
- tile (picker entry "teams") is built by teams-client.js, everything below
- draws the Plan tile.
+ The module presents three tiles and this is the entry for all of them: the
+ Teams tile (picker entry "teams") is built by teams-client.js and the Song
+ Key tile ("key") by key-client.js; everything below draws the Plan tile.
The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
@@ -13,6 +13,7 @@
the connection settings. */
import createTeams from "./teams-client.js";
+import createKey from "./key-client.js";
const REFRESH_SVG = ' ';
@@ -21,8 +22,9 @@ const MINUS_SVG = '
+
+
+
+ `;
+
+ const wrap = root.querySelector('.pk-wrap');
+ const keyEl = root.querySelector('.pk-key');
+ const nameEl = root.querySelector('.pk-name');
+ const nextEl = root.querySelector('.pk-next');
+
+ const cfg = () => moduleApi.instanceSettings || {};
+ const on = (key) => cfg()[key] !== false; // unset = shown
+
+ /* ── the item on screen, and the one after it ───────────────────── */
+
+ function items() {
+ return Array.isArray(state?.items) ? state.items : [];
+ }
+
+ function currentItem() {
+ const id = state?.live?.following ? state.live.currentItemId : '';
+ return id ? items().find((it) => it.id === id) || null : null;
+ }
+
+ /** The next item that actually has a key — a talk between two songs is skipped. */
+ function nextKeyed(after) {
+ const list = items();
+ const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
+ if (from <= 0) return null;
+ for (let i = from; i < list.length; i += 1) {
+ if (list[i].keyName) return list[i];
+ }
+ return null;
+ }
+
+ function titleOf(item) {
+ return item ? (item.song?.title || item.title || '') : '';
+ }
+
+ /* ── render ─────────────────────────────────────────────────────── */
+
+ function renderStatus() {
+ if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
+ if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
+ if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
+ if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
+ if (!state.reachable) {
+ return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
+ }
+ const item = currentItem();
+ if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
+ // The key on screen stays — the band still needs it — but a tile must
+ // never look confidently live while the feed that chose it is gone.
+ if (!state.live.available) {
+ return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
+ }
+ if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
+ if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
+ moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${item.keyName}` : ' — no key in the plan'}`);
+ }
+
+ /** "Bb" → "B♭", "F#m" → "F♯m" — for the plain-text line under the key. */
+ function prettyKey(text) {
+ const match = /^([A-G])([#b])(.*)$/.exec(String(text || '').trim());
+ return match ? `${match[1]}${match[2] === '#' ? '♯' : '♭'}${match[3]}` : String(text || '');
+ }
+
+ /**
+ * A key is a note, so it is worth setting properly: "Bb" and "F#m" read
+ * better with the accidental raised and the minor kept small.
+ */
+ function drawKey(text) {
+ keyEl.innerHTML = '';
+ const match = /^([A-G])([#b♯♭]?)(.*)$/.exec(String(text).trim());
+ if (!match) {
+ keyEl.textContent = text;
+ return;
+ }
+ const [, note, accidental, rest] = match;
+ keyEl.appendChild(document.createTextNode(note));
+ if (accidental) {
+ const sup = document.createElement('span');
+ sup.className = 'pk-accidental';
+ sup.textContent = accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental;
+ keyEl.appendChild(sup);
+ }
+ if (rest) {
+ const tail = document.createElement('span');
+ tail.className = 'pk-quality';
+ tail.textContent = rest;
+ keyEl.appendChild(tail);
+ }
+ }
+
+ function render() {
+ wrap.classList.toggle('is-offline', feedOffline);
+ const live = state?.live;
+ wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
+ const color = String(cfg().keyColor || '').trim();
+ wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
+ renderStatus();
+
+ const item = currentItem();
+ const key = item?.keyName || '';
+ wrap.classList.toggle('has-key', Boolean(key));
+
+ if (!state || !state.configured || !state.serviceTypeSet) {
+ drawKey('—');
+ nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
+ nextEl.textContent = '';
+ return;
+ }
+ if (!item) {
+ drawKey('—');
+ nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
+ nextEl.textContent = '';
+ return;
+ }
+
+ drawKey(key || '—');
+ nameEl.textContent = on('showSongName') ? titleOf(item) : '';
+ nameEl.hidden = !nameEl.textContent;
+
+ const next = on('showNextKey') ? nextKeyed(item) : null;
+ nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
+ nextEl.hidden = !nextEl.textContent;
+ }
+
+ /* ── live feed ──────────────────────────────────────────────────── */
+
+ function accept(payload) {
+ if (!payload || typeof payload !== 'object' || !payload.state) return;
+ state = payload.state;
+ feedOffline = false;
+ render();
+ }
+
+ function connect() {
+ stream = moduleApi.sse('/stream', {
+ open() { feedOffline = false; render(); },
+ error() { feedOffline = true; render(); },
+ events: {
+ state(ev) {
+ try {
+ accept(JSON.parse(ev.data));
+ } catch { /* a bad frame is not fatal */ }
+ },
+ },
+ });
+ }
+
+ /* ── lifecycle ──────────────────────────────────────────────────── */
+
+ return {
+ start() {
+ moduleApi.setStatus('connecting', 'Connecting…');
+ moduleApi.fetch('/state')
+ .then((res) => (res.ok ? res.json() : null))
+ .then((body) => { if (body && !state) accept(body); })
+ .catch(() => { /* the stream will cover it */ });
+ connect();
+ render();
+ },
+ stop() {
+ stream?.close();
+ stream = null;
+ root.innerHTML = '';
+ },
+ onConfigChange() {
+ // Admin holds the connection only; the server re-inits and re-sends.
+ },
+ };
+}
diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json
index efd7ecd..1b17e5b 100644
--- a/modules/pco-plan/module.json
+++ b/modules/pco-plan/module.json
@@ -1,9 +1,9 @@
{
"id": "pco-plan",
"name": "PCO Plan",
- "version": "1.8.0",
+ "version": "1.9.0",
"proddash": ">=1.4.0",
- "description": "Order of service and team assignments from Planning Center Services",
+ "description": "Order of service, team assignments and the song key from Planning Center Services",
"provides": [
"timers"
],
@@ -621,6 +621,36 @@
"columns": 2
}
}
+ },
+ {
+ "id": "key",
+ "name": "Song Key",
+ "description": "The key of the item on screen, big",
+ "defaultSize": {
+ "w": 2,
+ "h": 2
+ },
+ "minSize": {
+ "w": 1,
+ "h": 1
+ },
+ "instanceSchema": {
+ "keyColor": {
+ "type": "color",
+ "label": "Key",
+ "default": "#4ea1ff"
+ },
+ "showSongName": {
+ "type": "boolean",
+ "label": "Song name",
+ "default": true
+ },
+ "showNextKey": {
+ "type": "boolean",
+ "label": "Next key",
+ "default": true
+ }
+ }
}
]
}
diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css
index 284f37c..952d4fd 100644
--- a/modules/pco-plan/style.css
+++ b/modules/pco-plan/style.css
@@ -635,25 +635,105 @@
[data-module="pco-plan"] .pp-abs-sec {
flex: none;
- padding: 0.1em 0.5em;
- border-radius: 999px;
- border: 1px solid var(--border);
+ padding: 0.14em 0.55em;
+ /* The same rounded square, tinted ground and transparent border the AbleSet
+ module gives its own section pills, so the two read as one family. */
+ border-radius: 0.35em;
+ border: 1px solid transparent;
+ background: color-mix(in srgb, var(--text) 8%, var(--panel));
font-size: 0.72em;
font-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;
+ transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
/* The section now playing fills left to right as it goes, like the row. */
+/* The section playing keeps its own ground and fills across it, so the
+ progress reads against the chip rather than against the row behind it. */
[data-module="pco-plan"] .pp-abs-sec.is-now {
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%));
+ color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
+ color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
}
+
+/* ── the Song Key tile ────────────────────────────────────────────────
+ One value, as large as the tile allows. Sizes come from the tile itself
+ (container units), like the AbleSet module's single readouts, so a small
+ tile and a wall-sized one both read properly. */
+
+[data-module="pco-plan"] .pk-wrap {
+ --pk-color: #4ea1ff;
+ position: absolute;
+ inset: 0;
+ container-type: size;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.2em;
+ padding: 4%;
+ text-align: center;
+ user-select: none;
+}
+
+[data-module="pco-plan"] .pk-key {
+ font-size: clamp(18px, 46cqmin, 260px);
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: -0.01em;
+ color: var(--muted);
+ white-space: nowrap;
+}
+[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
+
+/* A key is a note, but it has to read as one word at a glance from the back
+ of a room: the accidental and the quality sit on the same line as the
+ letter, only smaller. Raising the accidental looked like two stacked
+ characters at tile sizes. */
+[data-module="pco-plan"] .pk-accidental {
+ font-size: 0.68em;
+ margin-left: 0.01em;
+}
+[data-module="pco-plan"] .pk-quality {
+ font-size: 0.5em;
+ font-weight: 700;
+ margin-left: 0.05em;
+ opacity: 0.85;
+}
+
+[data-module="pco-plan"] .pk-name {
+ max-width: 100%;
+ font-size: clamp(9px, 9cqmin, 30px);
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+[data-module="pco-plan"] .pk-name[hidden] { display: none; }
+
+[data-module="pco-plan"] .pk-next {
+ max-width: 100%;
+ font-size: clamp(8px, 6.5cqmin, 20px);
+ font-weight: 600;
+ color: var(--muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+[data-module="pco-plan"] .pk-next[hidden] { display: none; }
+
+[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
+/* The feed that picks the item is gone: the key stays readable, but faded,
+ so nobody trusts it as current without looking at the status dot. */
+[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
+[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
+[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }