From 09f59c1bef394f7ff082b5986b1b85db1d7c5935 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 26 Oct 2026 12:57:53 -0400 Subject: [PATCH] ProPresenter Look 1.0.0: client entry --- modules/propresenter-looks/client.js | 192 +++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 modules/propresenter-looks/client.js diff --git a/modules/propresenter-looks/client.js b/modules/propresenter-looks/client.js new file mode 100644 index 0000000..785337d --- /dev/null +++ b/modules/propresenter-looks/client.js @@ -0,0 +1,192 @@ +/* ProPresenter Look — client part. + + The server part polls ProPresenter and streams the live audience look + here over SSE; the tile only renders. The look's name fills the tile so + it reads from across the booth, flashes once when it changes, and stays + on screen dimmed — marked "last known" — while ProPresenter is + unreachable. */ + +export default function create({ root, moduleApi }) { + let view = null; + let feedOffline = false; + let stream = null; + let resizeObserver = null; + let flashTimer = null; + let lastName = null; + + root.innerHTML = ` +
+
+
Look
+
Connecting…
+
+
Connecting…
+ +
`; + + const screenEl = root.querySelector('.pl-screen'); + const topEl = root.querySelector('.pl-top'); + const headingEl = root.querySelector('.pl-heading'); + const liveEl = root.querySelector('.pl-live'); + const liveTextEl = root.querySelector('.pl-live-text'); + const nameBox = root.querySelector('.pl-name-box'); + const nameEl = root.querySelector('.pl-name'); + const metaEl = root.querySelector('.pl-meta'); + const tagEl = root.querySelector('.pl-tag'); + const sinceEl = root.querySelector('.pl-since'); + + const on = (key) => moduleApi.instanceSettings[key] !== false; + + function timeText(ms) { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + + /** Biggest font size at which the name fits its box without breaking a word. */ + function fitName() { + const MIN = 11; + const boxHeight = nameBox.clientHeight; + const boxWidth = nameBox.clientWidth; + nameEl.style.overflowWrap = 'normal'; + if (boxHeight < MIN || !boxWidth) { + nameEl.style.fontSize = `${MIN}px`; + return; + } + const fits = (size) => { + nameEl.style.fontSize = `${size}px`; + return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1; + }; + let lo = MIN; + let hi = Math.max(MIN, Math.floor(boxHeight)); + let best = MIN; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; } + } + nameEl.style.fontSize = `${best}px`; + // One word too long even at MIN: let it break rather than overflow. + if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere'; + } + + function flash() { + screenEl.classList.remove('is-changed'); + void screenEl.offsetWidth; // restart the animation + screenEl.classList.add('is-changed'); + clearTimeout(flashTimer); + flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600); + } + + function setStatus() { + if (feedOffline) { + moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); + } else if (!view) { + moduleApi.setStatus('connecting', 'Connecting…'); + } else if (!view.configured) { + moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + } else if (view.reachable) { + moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`); + } else if (!view.updatedAt && !view.lastError) { + moduleApi.setStatus('connecting', 'Reaching ProPresenter…'); + } else { + moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`); + } + } + + function render() { + setStatus(); + const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError)); + const look = view && view.look; + + screenEl.classList.toggle('is-offline', offline); + headingEl.hidden = !on('showHeading'); + liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows + topEl.hidden = headingEl.hidden && liveEl.hidden; + + liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable)); + liveEl.classList.toggle('is-down', offline); + if (feedOffline) liveTextEl.textContent = 'ProdDash offline'; + else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable'; + else if (view && view.reachable) liveTextEl.textContent = 'Live'; + else liveTextEl.textContent = 'Connecting…'; + + let name; + let waiting = false; + if (look) { + name = look.name; + } else if (view && !view.configured) { + name = 'Set up in Admin'; + waiting = true; + } else if (offline) { + name = 'ProPresenter unreachable'; + waiting = true; + } else { + name = 'Connecting…'; + waiting = true; + } + nameEl.textContent = name; + nameEl.classList.toggle('is-waiting', waiting); + + if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash(); + if (look) lastName = `${look.name}|${look.modified}`; + + tagEl.hidden = !(look && look.modified); + const since = look && view.changedAt && on('showSince'); + sinceEl.hidden = !since; + if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`; + metaEl.hidden = tagEl.hidden && sinceEl.hidden; + + fitName(); + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { + feedOffline = false; + render(); + }, + error() { + feedOffline = true; + render(); + }, + events: { + state(e) { + try { + view = JSON.parse(e.data); + } catch { + return; + } + feedOffline = false; + render(); + }, + }, + }); + } + + return { + start() { + render(); + moduleApi.fetch('/state') + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + if (body && body.state && !view) { + view = body.state; + render(); + } + }) + .catch(() => {}); + connect(); + resizeObserver = new ResizeObserver(() => fitName()); + resizeObserver.observe(nameBox); + }, + stop() { + if (stream) stream.close(); + stream = null; + if (resizeObserver) resizeObserver.disconnect(); + resizeObserver = null; + clearTimeout(flashTimer); + root.innerHTML = ''; + }, + }; +}