diff --git a/README.md b/README.md index f425f4b..0efedf6 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,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, @@ -357,6 +358,16 @@ 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, with the song's name and the next key under it — both + switches, as is the colour. When the feed that picks the item is gone the key + stays up, faded, and the tile's dot says so rather than pretending to be + current. + + Keys are written as notes wherever the module shows one: Planning Center's + `Bb` and `F#m` read as B♭ and F♯m, on the Plan tile's item badge and in the + Song Key tile alike. + **Teams** shows the same plan's team and position assignments — each team 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..685bc75 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,8 @@ the connection settings. */ import createTeams from "./teams-client.js"; +import createKey from "./key-client.js"; +import { prettyKey } from "./key-notation.js"; const REFRESH_SVG = ''; @@ -21,8 +23,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 ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`); + } + + /** + * A key is a note, so it is worth setting properly: "Bb" and "F#m" read + * better with the accidental raised and the minor kept small. + */ + function drawKey(text) { + keyEl.innerHTML = ''; + const parts = parseKey(text); + if (!parts) { + keyEl.textContent = text; + return; + } + keyEl.appendChild(document.createTextNode(parts.note)); + if (parts.accidental) { + const acc = document.createElement('span'); + acc.className = 'pk-accidental'; + acc.textContent = parts.accidental; + keyEl.appendChild(acc); + } + if (parts.quality) { + const tail = document.createElement('span'); + tail.className = 'pk-quality'; + tail.textContent = parts.quality; + keyEl.appendChild(tail); + } + } + + function render() { + wrap.classList.toggle('is-offline', feedOffline); + const live = state?.live; + wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable))); + const color = String(cfg().keyColor || '').trim(); + wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff'); + renderStatus(); + + const item = currentItem(); + const key = item?.keyName || ''; + wrap.classList.toggle('has-key', Boolean(key)); + + if (!state || !state.configured || !state.serviceTypeSet) { + drawKey('—'); + nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…'; + nextEl.textContent = ''; + return; + } + if (!item) { + drawKey('—'); + nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter'; + nextEl.textContent = ''; + return; + } + + drawKey(key || '—'); + nameEl.textContent = on('showSongName') ? titleOf(item) : ''; + nameEl.hidden = !nameEl.textContent; + + const next = on('showNextKey') ? nextKeyed(item) : null; + nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : ''; + nextEl.hidden = !nextEl.textContent; + } + + /* ── live feed ──────────────────────────────────────────────────── */ + + function accept(payload) { + if (!payload || typeof payload !== 'object' || !payload.state) return; + state = payload.state; + feedOffline = false; + render(); + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { feedOffline = false; render(); }, + error() { feedOffline = true; render(); }, + events: { + state(ev) { + try { + accept(JSON.parse(ev.data)); + } catch { /* a bad frame is not fatal */ } + }, + }, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting…'); + moduleApi.fetch('/state') + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { if (body && !state) accept(body); }) + .catch(() => { /* the stream will cover it */ }); + connect(); + render(); + }, + stop() { + stream?.close(); + stream = null; + root.innerHTML = ''; + }, + onConfigChange() { + // Admin holds the connection only; the server re-inits and re-sends. + }, + }; +} diff --git a/modules/pco-plan/key-notation.js b/modules/pco-plan/key-notation.js new file mode 100644 index 0000000..f5b7dba --- /dev/null +++ b/modules/pco-plan/key-notation.js @@ -0,0 +1,25 @@ +/* PCO Plan — how a song key is written. + + Planning Center stores keys as typed: "Bb", "F#m", "Ab". Every tile in this + module shows them as notes instead — B♭, F♯m, A♭ — so the plan, the badge + on an item and the Song Key tile all agree. Shared rather than repeated + because the day one of them changes, they all should. */ + +/** "F#m" → { note: 'F', accidental: '♯', quality: 'm' }; anything odd → null. */ +export function parseKey(raw) { + const match = /^([A-G])\s*([#b♯♭]?)\s*(.*)$/.exec(String(raw || '').trim()); + if (!match) return null; + const [, note, accidental, quality] = match; + return { + note, + accidental: accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental, + quality: quality.trim(), + }; +} + +/** The same thing as one string, for a badge or a caption. */ +export function prettyKey(raw) { + const parts = parseKey(raw); + if (!parts) return String(raw || '').trim(); + return `${parts.note}${parts.accidental}${parts.quality}`; +} 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..035ee48 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -206,7 +206,9 @@ [data-module="pco-plan"] .pp-now-tag { display: none; - padding: 0.05em 0.45em; + /* Optically centred like the section chips: uppercase has no descenders, + so the capitals sat 0.59px low in the pill. */ + padding: 0.018em 0.45em 0.082em; border-radius: 999px; background: var(--pp-current); color: var(--bg); @@ -635,25 +637,110 @@ [data-module="pco-plan"] .pp-abs-sec { flex: none; - padding: 0.1em 0.5em; - border-radius: 999px; - border: 1px solid var(--border); + /* Uppercase text has no descenders, so the font's descent allowance below + the baseline leaves the caps sitting high in the box — measured at 0.9px + too much headroom. The padding is a touch shallower on top and deeper + below to centre the letters themselves; in em, so it holds at any text + size. */ + padding: 0.095em 0.55em 0.185em; + /* The same rounded square, tinted ground and transparent border the AbleSet + module gives its own section pills, so the two read as one family. */ + border-radius: 0.35em; + border: 1px solid transparent; + background: color-mix(in srgb, var(--text) 8%, var(--panel)); font-size: 0.72em; font-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; }