/* 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 = ''; }, }; }