diff --git a/README.md b/README.md index 70b747b..85baf5e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index fb6e1a3..451bdda 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -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(); + }, }, }); } diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 6c0d9f6..efd7ecd 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -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": [ diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index d1136bd..89ef471 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -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 */ } diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 3b8561f..284f37c 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -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%)); +}