2026-09-27 11:18:25 -04:00
|
|
|
/* PCO Plan — the Song Key tile.
|
|
|
|
|
|
|
|
|
|
One thing, big: the key of the plan item on screen. It reads the same
|
|
|
|
|
streamed state the Plan tile does, so it needs no connection of its own;
|
|
|
|
|
which item is current comes from the ProPresenter module, as ever. */
|
|
|
|
|
|
2026-09-27 11:27:31 -04:00
|
|
|
import { parseKey, prettyKey } from "./key-notation.js";
|
|
|
|
|
|
2026-09-27 11:18:25 -04:00
|
|
|
export default function createKey({ root, moduleApi }) {
|
|
|
|
|
let state = null;
|
|
|
|
|
let stream = null;
|
|
|
|
|
let feedOffline = false;
|
|
|
|
|
|
|
|
|
|
root.innerHTML = `
|
|
|
|
|
<div class="pk-wrap">
|
|
|
|
|
<div class="pk-key"></div>
|
|
|
|
|
<div class="pk-name"></div>
|
|
|
|
|
<div class="pk-next"></div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
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');
|
2026-09-27 11:27:31 -04:00
|
|
|
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${prettyKey(item.keyName)}` : ' — no key in the plan'}`);
|
2026-09-27 11:18:25 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 = '';
|
2026-09-27 11:27:31 -04:00
|
|
|
const parts = parseKey(text);
|
|
|
|
|
if (!parts) {
|
2026-09-27 11:18:25 -04:00
|
|
|
keyEl.textContent = text;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-27 11:27:31 -04:00
|
|
|
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);
|
2026-09-27 11:18:25 -04:00
|
|
|
}
|
2026-09-27 11:27:31 -04:00
|
|
|
if (parts.quality) {
|
2026-09-27 11:18:25 -04:00
|
|
|
const tail = document.createElement('span');
|
|
|
|
|
tail.className = 'pk-quality';
|
2026-09-27 11:27:31 -04:00
|
|
|
tail.textContent = parts.quality;
|
2026-09-27 11:18:25 -04:00
|
|
|
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.
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|