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