193 lines
6.4 KiB
JavaScript
193 lines
6.4 KiB
JavaScript
/* 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 = `
|
||
|
|
<div class="pl-screen">
|
||
|
|
<header class="pl-top">
|
||
|
|
<div class="pl-heading">Look</div>
|
||
|
|
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
|
||
|
|
</header>
|
||
|
|
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
|
||
|
|
<footer class="pl-meta">
|
||
|
|
<span class="pl-tag" hidden>Modified</span>
|
||
|
|
<span class="pl-since" hidden></span>
|
||
|
|
</footer>
|
||
|
|
</div>`;
|
||
|
|
|
||
|
|
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 = '';
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|