ProPresenter Look 1.0.0: a new module showing the live audience look #55
@@ -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 = `
|
||||
<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 = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user