/* 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. */ import { parseKey, prettyKey } from "./key-notation.js"; import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js"; export default function createKey({ root, moduleApi }) { let state = null; let stream = null; let feedOffline = false; let view = viewOf(null); let offViewed = null; root.innerHTML = `
`; 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 switcher = addServiceTypeSwitch({ moduleApi, getState: () => state }); 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(view.items) ? view.items : []; } function currentItem() { // Only the followed service type has a live item; on another one there // is nothing current, so the tile says so rather than guessing. const id = view.live && 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 (!view.live) return moduleApi.setStatus('ok', `${view.name || 'Another service type'} — not the one live`); 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() { view = viewOf(state); switcher.sync(); wrap.classList.toggle('is-offline', feedOffline); wrap.classList.toggle('is-other-type', Boolean(state) && !view.live); 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 = !view.live ? (view.name || 'Another service type') : (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(); offViewed = onViewedType(render); render(); }, stop() { offViewed?.(); offViewed = null; stream?.close(); stream = null; root.innerHTML = ''; }, onConfigChange() { // Admin holds the connection only; the server re-inits and re-sends. }, }; }